Jump to content

User:Polygnotus/Scripts/GetAllContribs.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.
// <nowiki>
// GetAllContribs - fetches all edits of a list of usernames and exports them as a text file.
// Lives at Special:BlankPage/GetAllContribs and adds a link in the tools menu.

$(document).ready(function () {
    // Add link to tools menu on every page
    mw.util.addPortletLink(
        'p-tb',                                  // portlet ID (tools menu)
        mw.util.getUrl('Special:BlankPage/GetAllContribs'),
        'GetAllContribs',                        // link text
        't-getallcontribs',                      // link ID
        'Fetch contributions for a list of accounts'
    );

    // Only build the UI when actually viewing the dedicated page.
    // We can't compare wgPageName against a fixed English string because the
    // Special namespace and the special page title are localised on non-English
    // wikis (e.g. de.wiki has "Spezial:Leere_Seite/GetAllContribs"). Instead,
    // check the canonical special page name and parse the subpage portion.
    if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage') {
        return;
    }
    var fullPageName = mw.config.get('wgPageName');
    var firstSlash = fullPageName.indexOf('/');
    var subpageName = (firstSlash >= 0) ? fullPageName.substring(firstSlash + 1) : '';
    if (subpageName !== 'GetAllContribs') {
        return;
    }

    // ----- Page setup -----
    document.title = 'GetAllContribs';
    $('#firstHeading').text('GetAllContribs');

    // Build the UI inside the content area
    var $content = $('#mw-content-text');
    $content.empty();

    var $wrapper = $('<div>').css({ 'max-width': '900px' });

    $wrapper.append(
        $('<p>').text(
            'Enter one username per line. Click "Run" to fetch contributions and download the report as a text file.'
        )
    );

    var $textarea = $('<textarea>')
        .attr('id', 'gac-usernames')
        .attr('rows', 12)
        .attr('placeholder', 'One username per line')
        .css({ 'width': '100%', 'font-family': 'monospace' });

    // Mode selector: edit summaries only, diffs only, or both
    var $modeBox = $('<fieldset>').css({ 'margin': '0.75em 0', 'padding': '0.5em 0.75em' });
    $modeBox.append($('<legend>').text('Collect'));

    var modes = [
        { value: 'both',     label: 'Edit summaries and diffs (default)' },
        { value: 'summary',  label: 'Edit summaries only (fast - no diff API calls)' },
        { value: 'diff',     label: 'Diffs only (no edit summary in output)' }
    ];

    for (var m = 0; m < modes.length; m++) {
        var id = 'gac-mode-' + modes[m].value;
        var $label = $('<label>').css({ 'display': 'block', 'margin': '0.15em 0' });
        var $radio = $('<input>')
            .attr('type', 'radio')
            .attr('name', 'gac-mode')
            .attr('id', id)
            .attr('value', modes[m].value)
            .css({ 'margin-right': '0.4em' });
        if (modes[m].value === 'both') $radio.prop('checked', true);
        $label.append($radio, document.createTextNode(modes[m].label));
        $modeBox.append($label);
    }

    var $runBtn   = $('<button>').attr('id', 'gac-run').text('Run').css({ 'margin-right': '0.5em' });
    var $stopBtn  = $('<button>').attr('id', 'gac-stop').text('Stop').prop('disabled', true);
    var $status   = $('<div>').attr('id', 'gac-status').css({ 'margin': '0.75em 0', 'font-weight': 'bold' });
    var $progress = $('<div>').attr('id', 'gac-progress').css({
        'margin': '0.25em 0 0.75em 0',
        'font-family': 'monospace'
    });
    var $log      = $('<pre>').attr('id', 'gac-log').css({
        'max-height': '400px',
        'overflow': 'auto',
        'background': '#f8f9fa',
        'border': '1px solid #c8ccd1',
        'padding': '0.5em',
        'white-space': 'pre-wrap'
    });

    $wrapper.append(
        $textarea,
        $modeBox,
        $('<div>').css('margin-top', '0.5em').append($runBtn, $stopBtn),
        $status,
        $progress,
        $log
    );
    $content.append($wrapper);

    // ----- Worker logic -----
    var API = mw.util.wikiScript('api'); // path to api.php on this wiki
    var INDEX_SCRIPT = mw.util.wikiScript('index'); // path to index.php (for diff URLs)
    var DELAY_MS = 1500;
    var MAX_DIFF_CHARS = 4000;
    var MAXLAG = 5;                    // seconds; server errors out if replication lag exceeds this
    var MAX_RETRIES = 6;               // per-request cap before giving up
    var INITIAL_BACKOFF_MS = 1000;
    var MAX_BACKOFF_MS = 30000;
    var REQUEST_TIMEOUT_MS = 60000;    // per-request timeout: aborts hung connections

    // ucuser / ususers accept up to 50 names per call for regular accounts
    // (500 with apihighlimits). uclimit=max is a global cap across the whole batch,
    // not per user, so batching mainly helps when most accounts have few edits.
    // Heavy accounts still need many continuation rounds regardless of batch size.
    var BATCH_SIZE = 50;

    // Api-User-Agent header (browsers won't let us set User-Agent directly,
    // but MediaWiki reads this as a fallback per the User-Agent policy).
    var USER_AGENT = 'GetAllContribs/1.6 (user script; ' +
        (mw.config.get('wgUserName') ? 'User:' + mw.config.get('wgUserName') : 'anonymous') +
        '; ' + location.hostname + ')';

    var cancelRequested = false;
    var running = false;
    var activeAbortControllers = new Set();
    var sleepRejecters = new Set();

    // Cancellation-aware sleep: rejects with Error('cancelled') if Stop is hit mid-wait.
    function sleep(ms) {
        return new Promise(function (resolve, reject) {
            if (cancelRequested) { reject(new Error('cancelled')); return; }
            var rejecter = function () { clearTimeout(t); sleepRejecters.delete(rejecter); reject(new Error('cancelled')); };
            var t = setTimeout(function () {
                sleepRejecters.delete(rejecter);
                resolve();
            }, ms);
            sleepRejecters.add(rejecter);
        });
    }

    function abortAllInFlight() {
        // Abort any pending fetch
        activeAbortControllers.forEach(function (ac) {
            try { ac.abort(); } catch (e) { /* ignore */ }
        });
        // Wake any pending sleep
        sleepRejecters.forEach(function (fn) {
            try { fn(); } catch (e) { /* ignore */ }
        });
    }

    function setStatus(text)   { $status.text(text); }
    function setProgress(text) { $progress.text(text); }
    function logLine(text)     { $log.append(document.createTextNode(text + '\n')); $log.scrollTop($log[0].scrollHeight); }

    function getMode() {
        var v = $('input[name="gac-mode"]:checked').val();
        return v || 'both';
    }

    // MediaWiki canonicalises usernames before storage. Match its rules so we can
    // group results returned by the API back to the input names the user typed:
    //  - apply Unicode NFC normalisation (MediaWiki does this on titles/usernames)
    //  - trim and convert underscores to spaces
    //  - IPv6 addresses are stored fully uppercase (the API returns them in this
    //    canonical form, so simple uppercase is sufficient for grouping)
    //  - IPv4 addresses are unchanged by case
    //  - regular usernames have only their first character uppercased
    function isIPv6(s) {
        // Real IPv6 addresses always contain at least two colons (one between
        // every pair of hextets, or the "::" zero-run shorthand). Restricting
        // to >=2 colons avoids matching strings like "abc:def" that happen to
        // contain a single colon.
        if (s.split(':').length < 3) return false;
        return /^[0-9A-Fa-f:.]+$/.test(s);
    }

    function normalizeUsername(name) {
        name = String(name || '');
        // String.prototype.normalize is available in all modern browsers.
        try { name = name.normalize('NFC'); } catch (e) { /* ignore on ancient engines */ }
        name = name.trim().replace(/_/g, ' ');
        if (name.length === 0) return name;
        if (isIPv6(name)) return name.toUpperCase();
        return name.charAt(0).toUpperCase() + name.slice(1);
    }

    // Truncate by Unicode code points (not UTF-16 code units) so we never split
    // a surrogate pair mid-character.
    function truncateForDiff(s) {
        var arr = Array.from(s);
        if (arr.length <= MAX_DIFF_CHARS) return s;
        return arr.slice(0, MAX_DIFF_CHARS).join('') + '\n...(truncated)';
    }

    // Centralised API request: adds maxlag + Api-User-Agent, handles retries with
    // exponential backoff for maxlag, ratelimited, network errors, timeouts, and
    // 5xx responses. Throws Error('cancelled') promptly when Stop is pressed.
    // Uses POST so long pipe-separated user lists never hit URL length limits.
    async function apiRequest(params) {
        params.set('maxlag', String(MAXLAG));

        var attempt = 0;
        var backoff = INITIAL_BACKOFF_MS;

        while (true) {
            if (cancelRequested) throw new Error('cancelled');
            attempt++;

            var ac = new AbortController();
            activeAbortControllers.add(ac);

            // Per-request timeout. We track timedOut separately from cancelRequested
            // so we can distinguish a stalled connection from a user-initiated stop.
            var timedOut = false;
            var timeoutId = setTimeout(function () {
                timedOut = true;
                try { ac.abort(); } catch (e) { /* ignore */ }
            }, REQUEST_TIMEOUT_MS);

            try {
                var res, data, retryAfter, reason;

                try {
                    res = await fetch(API, {
                        method: 'POST',
                        headers: {
                            'Api-User-Agent': USER_AGENT,
                            'Content-Type': 'application/x-www-form-urlencoded'
                        },
                        body: params.toString(),
                        credentials: 'same-origin',
                        signal: ac.signal
                    });
                } catch (e) {
                    if (cancelRequested) throw new Error('cancelled');
                    reason = timedOut
                        ? 'request timeout (' + (REQUEST_TIMEOUT_MS / 1000) + 's)'
                        : 'network error: ' + e.message;
                    if (attempt >= MAX_RETRIES) throw new Error(reason);
                    logLine('  ' + reason + ' - retrying in ' + Math.round(backoff / 1000) + 's (attempt ' + attempt + '/' + MAX_RETRIES + ')');
                    await sleep(backoff);
                    backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
                    continue;
                }

                // Server-side errors (rare, but possible)
                if (res.status >= 500 && res.status < 600) {
                    retryAfter = parseInt(res.headers.get('Retry-After'), 10);
                    reason = 'HTTP ' + res.status;
                    if (attempt >= MAX_RETRIES) throw new Error(reason);
                    var wait5xx = (retryAfter > 0 ? retryAfter * 1000 : backoff);
                    logLine('  ' + reason + ' - retrying in ' + Math.round(wait5xx / 1000) + 's (attempt ' + attempt + '/' + MAX_RETRIES + ')');
                    await sleep(wait5xx);
                    backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
                    continue;
                }

                try {
                    data = await res.json();
                } catch (e) {
                    if (cancelRequested) throw new Error('cancelled');
                    reason = timedOut
                        ? 'timeout reading body (' + (REQUEST_TIMEOUT_MS / 1000) + 's)'
                        : 'invalid JSON: ' + e.message;
                    if (attempt >= MAX_RETRIES) throw new Error(reason);
                    logLine('  ' + reason + ' - retrying in ' + Math.round(backoff / 1000) + 's (attempt ' + attempt + '/' + MAX_RETRIES + ')');
                    await sleep(backoff);
                    backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
                    continue;
                }

                // Maxlag: server is lagging, back off using Retry-After if provided
                if (data.error && data.error.code === 'maxlag') {
                    retryAfter = parseInt(res.headers.get('Retry-After'), 10);
                    var waitLag = (retryAfter > 0 ? retryAfter * 1000 : backoff);
                    if (attempt >= MAX_RETRIES) throw new Error('maxlag: ' + data.error.info);
                    logLine('  maxlag - waiting ' + Math.round(waitLag / 1000) + 's (attempt ' + attempt + '/' + MAX_RETRIES + ')');
                    await sleep(waitLag);
                    backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
                    continue;
                }

                // Rate limited: also honour Retry-After when provided
                if (data.error && data.error.code === 'ratelimited') {
                    retryAfter = parseInt(res.headers.get('Retry-After'), 10);
                    var waitRl = (retryAfter > 0 ? retryAfter * 1000 : backoff);
                    if (attempt >= MAX_RETRIES) throw new Error('ratelimited: ' + data.error.info);
                    logLine('  ratelimited - waiting ' + Math.round(waitRl / 1000) + 's (attempt ' + attempt + '/' + MAX_RETRIES + ')');
                    await sleep(waitRl);
                    backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
                    continue;
                }

                // Other API errors are surfaced to the caller (they may be expected, e.g. nosuchrev)
                return data;
            } finally {
                clearTimeout(timeoutId);
                activeAbortControllers.delete(ac);
            }
        }
    }

    // Fetch editcount and canonical name for a batch of users via list=users.
    // Returns { counts: Map<canonicalName, number|null>, canonicalByInput: Map<inputCanonical, apiCanonical> }
    //  - counts: editcount per canonical name; null means unknown - this happens
    //    for IP addresses (invalid for list=users), missing/renamed accounts, or
    //    anything else the API does not return an editcount for.
    //  - canonicalByInput: maps the canonical form of each input name to the
    //    canonical name returned by the API. For renamed accounts these differ:
    //    the old name was queryable, but list=users returns the *current* name.
    async function getEditcountsBatch(users) {
        var counts = new Map();
        var canonicalByInput = new Map();
        for (var i = 0; i < users.length; i++) {
            var inputCanonical = normalizeUsername(users[i]);
            counts.set(inputCanonical, null);
            // Default: assume input name == api name. Will be overwritten below
            // if the API returns a different canonical form.
            canonicalByInput.set(inputCanonical, inputCanonical);
        }

        var params = new URLSearchParams({
            action: 'query',
            format: 'json',
            formatversion: '2',
            list: 'users',
            ususers: users.join('|'),
            usprop: 'editcount'
        });

        var data = await apiRequest(params);
        if (data.error) {
            throw new Error('API error: ' + data.error.code + ' - ' + data.error.info);
        }

        if (data.query && Array.isArray(data.query.users)) {
            var arr = data.query.users;
            // The API echoes ususers in order: arr[i] corresponds to users[i].
            // Each entry has a .name field which is the canonical name the API
            // uses internally. For renamed accounts this differs from the input
            // name and is what usercontribs will return in c.user.
            for (var j = 0; j < arr.length; j++) {
                var u = arr[j];
                if (j < users.length) {
                    var inCanon = normalizeUsername(users[j]);
                    if (u && u.name) {
                        canonicalByInput.set(inCanon, normalizeUsername(u.name));
                    }
                }
                if (!u || u.missing || u.invalid) continue;
                if (typeof u.editcount === 'number') {
                    counts.set(normalizeUsername(u.name), u.editcount);
                }
            }
        }

        return { counts: counts, canonicalByInput: canonicalByInput };
    }

    // Fetch contribs for a batch of users in a single continuation loop.
    // Returns Map<canonicalName, contribs[]>. Names with no contribs map to [].
    async function getContribsBatch(users) {
        var byUser = new Map();
        for (var i = 0; i < users.length; i++) {
            byUser.set(normalizeUsername(users[i]), []);
        }

        var hasCont = false;
        var contParams = {};
        do {
            if (cancelRequested) throw new Error('cancelled');
            var params = new URLSearchParams({
                action: 'query',
                format: 'json',
                formatversion: '2',
                list: 'usercontribs',
                ucuser: users.join('|'),
                uclimit: 'max',
                ucprop: 'ids|title|timestamp|comment|sizediff|flags'
            });
            // Per the MediaWiki API contract, replay every key returned in the
            // continue object - not just uccontinue. The bare 'continue' marker
            // is part of the protocol and additional keys may appear in future.
            if (hasCont) {
                Object.keys(contParams).forEach(function (k) {
                    params.set(k, contParams[k]);
                });
            }

            var data = await apiRequest(params);

            if (data.error) {
                throw new Error('API error: ' + data.error.code + ' - ' + data.error.info);
            }

            if (data.query && data.query.usercontribs) {
                var contribs = data.query.usercontribs;
                for (var j = 0; j < contribs.length; j++) {
                    var c = contribs[j];
                    var key = normalizeUsername(c.user || '');
                    if (!byUser.has(key)) byUser.set(key, []);
                    byUser.get(key).push(c);
                }
            }

            if (data['continue']) {
                hasCont = true;
                contParams = {};
                Object.keys(data['continue']).forEach(function (k) {
                    contParams[k] = data['continue'][k];
                });
            } else {
                hasCont = false;
            }
            if (hasCont) await sleep(DELAY_MS);
        } while (hasCont);

        return byUser;
    }

    // Parse the diff HTML into removed/added line text with line-number markers.
    // Uses DOMParser instead of innerHTML to avoid executing any embedded resources
    // (e.g. <img onerror=...>) if the API ever returned unsanitised content.
    function parseDiff(diffHTML) {
        if (!diffHTML) return '(no diff returned)';

        var doc = new DOMParser().parseFromString(
            '<!DOCTYPE html><html><body><table>' + diffHTML + '</table></body></html>',
            'text/html'
        );

        var out = [];
        var rows = doc.querySelectorAll('tr');

        for (var i = 0; i < rows.length; i++) {
            var row = rows[i];
            var lineno = row.querySelector('.diff-lineno');
            if (lineno) {
                out.push('\n[' + lineno.textContent.trim() + ']');
                continue;
            }

            var removed = row.querySelector('.diff-deletedline');
            var added   = row.querySelector('.diff-addedline');

            if (removed || added) {
                if (removed) {
                    var rText = removed.textContent.replace(/\s+/g, ' ').trim();
                    if (rText) out.push('- ' + rText);
                }
                if (added) {
                    var aText = added.textContent.replace(/\s+/g, ' ').trim();
                    if (aText) out.push('+ ' + aText);
                }
            }
        }

        var result = out.join('\n').trim();
        if (!result) return '(no textual changes detected)';
        return truncateForDiff(result);
    }

    // For first edits (parentid = 0), the action=compare API needs an explicit
    // content model when comparing against empty fromtext, otherwise it defaults
    // to wikitext - which is wrong for JS, CSS, JSON, Lua/Scribunto, etc.
    // Instead of hardcoding the content model, fetch the revision's content
    // directly via prop=revisions and synthesise a diff where every line is
    // an addition. This is one API call (same cost as the compare call would
    // have been) and works for any content model.
    async function getFirstEditDiff(revid) {
        var params = new URLSearchParams({
            action: 'query',
            format: 'json',
            formatversion: '2',
            prop: 'revisions',
            revids: String(revid),
            rvprop: 'content|contentmodel',
            rvslots: 'main'
        });

        try {
            var data = await apiRequest(params);
            if (data.error) return '(API error: ' + data.error.info + ')';
            if (!data.query || !Array.isArray(data.query.pages) || data.query.pages.length === 0) {
                return '(could not fetch first-edit content)';
            }
            var page = data.query.pages[0];
            if (!page.revisions || page.revisions.length === 0) {
                return '(could not fetch first-edit content)';
            }
            var rev = page.revisions[0];
            var slot = rev.slots && rev.slots.main;
            if (!slot) return '(no main slot)';
            if (slot.contentmissing) return '(content missing)';
            if (slot.texthidden) return '(content hidden / revdel)';
            var content = slot.content;
            if (content == null) return '(content unavailable)';
            var contentModel = slot.contentmodel || 'unknown';

            // Treat every line as an addition. Strip CR and tabs so they don't
            // clobber output formatting; preserve the rest verbatim.
            var lines = content.split('\n');
            var added = [];
            for (var i = 0; i < lines.length; i++) {
                var t = lines[i].replace(/[\r\t]/g, ' ');
                added.push('+ ' + t);
            }
            var header = '(first edit on page; content model: ' + contentModel + ')';
            return truncateForDiff(header + '\n' + added.join('\n'));
        } catch (e) {
            // Re-throw cancellation so the outer loop stops cleanly instead of
            // writing "(fetch error: cancelled)" entries into the report.
            if (e.message === 'cancelled') throw e;
            return '(fetch error: ' + e.message + ')';
        }
    }

    async function getDiff(revid, parentid) {
        if (!parentid || parentid <= 0) {
            // First edit on the page - handle separately to avoid the wikitext
            // assumption baked into action=compare with empty fromtext.
            return getFirstEditDiff(revid);
        }

        var params = new URLSearchParams({
            action: 'compare',
            format: 'json',
            formatversion: '2',
            fromrev: String(parentid),
            torev: String(revid),
            prop: 'diff'
        });

        try {
            var data = await apiRequest(params);
            if (data.error) return '(API error: ' + data.error.info + ')';
            var diffHTML = (data.compare && (data.compare.body || data.compare['*'])) || '';
            return parseDiff(diffHTML);
        } catch (e) {
            if (e.message === 'cancelled') throw e;
            return '(fetch error: ' + e.message + ')';
        }
    }

    // Normalise a one-line value: replace newlines/CR/tabs with a single space.
    // Internal whitespace runs are preserved (we don't want to silently change
    // a comment that contains, say, deliberate double-spacing).
    function oneLine(s) {
        return String(s).replace(/[\r\n\t]+/g, ' ').trim();
    }

    // Format a number with thousands separators. Locale fixed to en-US for
    // stable, predictable output in the report file.
    function fmt(n) {
        return Number(n).toLocaleString('en-US');
    }

    async function run() {
        if (running) return;
        // Set running immediately to close the re-entry window. Any early
        // return below resets it before exiting.
        running = true;

        var raw = $textarea.val();

        // Parse usernames and deduplicate by canonical form. Keeping the first
        // occurrence preserves whatever spelling the user typed for display
        // purposes, while preventing the same account from being processed
        // twice (which would double-count edits in the progress denominator).
        var seen = new Set();
        var skipped = 0;
        var usernames = [];
        var lines = raw.split('\n');
        for (var li = 0; li < lines.length; li++) {
            var s = lines[li].trim();
            if (s.length === 0) continue;
            var canonical = normalizeUsername(s);
            if (canonical.length === 0) { skipped++; continue; }
            if (seen.has(canonical)) { skipped++; continue; }
            seen.add(canonical);
            usernames.push(s);
        }

        if (usernames.length === 0) {
            setStatus('No usernames provided.');
            running = false;
            return;
        }

        var mode = getMode();
        var includeSummary = (mode === 'both' || mode === 'summary');
        var includeDiff    = (mode === 'both' || mode === 'diff');

        cancelRequested = false;
        $runBtn.prop('disabled', true);
        $stopBtn.prop('disabled', false);
        $log.empty();
        setProgress('');

        if (skipped > 0) {
            logLine('Note: ' + skipped + ' duplicate or empty entries were skipped.');
        }

        var modeLabel = (mode === 'summary') ? 'edit summaries only'
                      : (mode === 'diff')    ? 'diffs only'
                      :                        'edit summaries and diffs';

        // Use an array of fragments that we hand directly to Blob() at the end.
        // Blob accepts a BlobPart[], so we never need to .join('') the array
        // into one giant string - that would double peak memory for large
        // reports (200MB+ on heavy editors with diffs enabled).
        var out = [];
        function w(s) { out.push(s); }

        var cancelledMidRun = false;

        try {
            // Split usernames into batches of BATCH_SIZE
            var batches = [];
            for (var b = 0; b < usernames.length; b += BATCH_SIZE) {
                batches.push(usernames.slice(b, b + BATCH_SIZE));
            }

            // Phase 1: fetch editcount for every user so we can show real progress.
            // Note: editcount from list=users (user_editcount) and the count from
            // usercontribs can diverge for several reasons - deleted edits are
            // included in user_editcount but not returned by usercontribs, and
            // user_editcount is denormalised so it can lag behind reality after
            // renames/imports/merges. We treat editcount as an estimate and
            // recalibrate the running total once we have actual contrib counts
            // per user.
            // We also collect canonical-name mapping here so that contribs
            // returned under a renamed account's *current* name get reported
            // against the input name the user typed.
            setStatus('Fetching edit counts...');
            logLine('Fetching edit counts for ' + usernames.length + ' user(s)...');

            var editcountByCanonical = new Map();
            // Maps canonical(input name) -> canonical(api name). Identity for
            // most users; differs for renamed accounts.
            var apiNameByInputCanonical = new Map();
            for (var pi = 0; pi < batches.length; pi++) {
                if (cancelRequested) { cancelledMidRun = true; break; }
                setStatus('Fetching edit counts (batch ' + (pi + 1) + '/' + batches.length + ')...');
                try {
                    var ecResult = await getEditcountsBatch(batches[pi]);
                    ecResult.counts.forEach(function (v, k) { editcountByCanonical.set(k, v); });
                    ecResult.canonicalByInput.forEach(function (v, k) { apiNameByInputCanonical.set(k, v); });
                } catch (e) {
                    if (e.message === 'cancelled') { cancelledMidRun = true; break; }
                    logLine('Edit count fetch failed for batch ' + (pi + 1) + ': ' + e.message);
                    // Fall back to identity mapping for this batch's users so
                    // contrib lookup still attempts something sensible.
                    for (var fb = 0; fb < batches[pi].length; fb++) {
                        var fbCanon = normalizeUsername(batches[pi][fb]);
                        if (!apiNameByInputCanonical.has(fbCanon)) {
                            apiNameByInputCanonical.set(fbCanon, fbCanon);
                        }
                    }
                }
                if (pi < batches.length - 1 && !cancelRequested) {
                    try { await sleep(DELAY_MS); }
                    catch (e) {
                        if (e.message === 'cancelled') { cancelledMidRun = true; break; }
                        throw e;
                    }
                }
            }

            // Compute initial total estimate. Edit counts are stored against
            // the *api* canonical name, so look them up via the rename mapping.
            var expectedTotal = 0;
            var unknownCountUsers = 0;
            for (var et = 0; et < usernames.length; et++) {
                var etInput = normalizeUsername(usernames[et]);
                var etApi = apiNameByInputCanonical.get(etInput) || etInput;
                var etCount = editcountByCanonical.get(etApi);
                if (typeof etCount === 'number') expectedTotal += etCount;
                else unknownCountUsers++;
            }

            if (!cancelledMidRun) {
                logLine('Estimated total edits: ' + fmt(expectedTotal) +
                    (unknownCountUsers > 0 ? ' (+' + unknownCountUsers + ' user(s) with unknown count)' : ''));
            }

            // Write report header (after phase 1 so we can include the estimate)
            w('Account contribution report\n');
            w('Generated: ' + new Date().toISOString() + '\n');
            w('Wiki: ' + location.hostname + '\n');
            w('User-Agent: ' + USER_AGENT + '\n');
            w('Mode: ' + modeLabel + '\n');
            w('Accounts queried: ' + usernames.length + '\n');
            if (skipped > 0) {
                w('Duplicate/empty entries skipped: ' + skipped + '\n');
            }
            w('Estimated total edits: ' + fmt(expectedTotal) +
                (unknownCountUsers > 0 ? ' (+' + unknownCountUsers + ' with unknown count)' : '') + '\n');
            w('Batch size: ' + BATCH_SIZE + ' (' + batches.length + ' batch' + (batches.length === 1 ? '' : 'es') + ')\n');
            w('='.repeat(80) + '\n');

            // Phase 2: process batches.
            // editsDone counts edits processed across the whole run.
            // dynamicTotal is the running denominator: starts as expectedTotal and
            // is recalibrated per user as actual contribs.length becomes known.
            var processedUsers = 0;
            var editsDone = 0;
            var dynamicTotal = expectedTotal;

            function updateProgress(currentUser, editIdx, userTotal) {
                var parts = [];
                parts.push('User ' + processedUsers + '/' + usernames.length);
                if (currentUser) parts.push(currentUser);
                if (editIdx != null) {
                    parts.push('edit ' + fmt(editIdx) + '/' + (userTotal != null ? fmt(userTotal) : '?'));
                }
                var totalStr = 'overall ' + fmt(editsDone) + '/' + fmt(dynamicTotal);
                if (dynamicTotal > 0) {
                    var pct = (100 * editsDone / dynamicTotal);
                    totalStr += ' (' + (pct >= 99.95 ? '100' : pct.toFixed(1)) + '%)';
                }
                parts.push(totalStr);
                setProgress(parts.join(' - '));
            }

            outer:
            for (var bi = 0; bi < batches.length; bi++) {
                if (cancelRequested) { cancelledMidRun = true; logLine('Cancelled by user.'); break; }

                var batch = batches[bi];
                var batchHeader = 'Batch ' + (bi + 1) + '/' + batches.length +
                                  ' - fetching contribs for ' + batch.length + ' user(s)...';
                setStatus(batchHeader);
                logLine(batchHeader);

                var byUser;
                try {
                    byUser = await getContribsBatch(batch);
                } catch (e) {
                    if (e.message === 'cancelled') { cancelledMidRun = true; logLine('Cancelled by user.'); break; }
                    logLine('Batch fetch failed: ' + e.message);
                    // Record errors for every user in this batch and continue
                    for (var k = 0; k < batch.length; k++) {
                        var bUser = batch[k];
                        var bInputCanon = normalizeUsername(bUser);
                        var bApiCanon = apiNameByInputCanonical.get(bInputCanon) || bInputCanon;
                        var bEstimate = editcountByCanonical.get(bApiCanon);
                        // Remove this user's estimate from the denominator since
                        // we will not be processing their edits.
                        if (typeof bEstimate === 'number') dynamicTotal -= bEstimate;

                        w('\n' + '='.repeat(80) + '\n');
                        w('User: ' + bUser + '\n');
                        w('='.repeat(80) + '\n');
                        w('(error fetching contributions: ' + e.message + ')\n');
                    }
                    processedUsers += batch.length;
                    updateProgress(null, null, null);
                    continue;
                }

                // Recalibrate dynamicTotal: replace each user's pre-pass estimate
                // with their actual contribs.length. Look up contribs under the
                // api canonical name (which is what usercontribs returns in c.user)
                // even when the input name was an old/renamed alias.
                for (var rb = 0; rb < batch.length; rb++) {
                    var rbInputCanon = normalizeUsername(batch[rb]);
                    var rbApiCanon = apiNameByInputCanonical.get(rbInputCanon) || rbInputCanon;
                    var rbActual = (byUser.get(rbApiCanon) || byUser.get(rbInputCanon) || []).length;
                    var rbEstimate = editcountByCanonical.get(rbApiCanon);
                    if (typeof rbEstimate === 'number') {
                        dynamicTotal += (rbActual - rbEstimate);
                    } else {
                        dynamicTotal += rbActual;
                    }
                }

                // Track which keys in byUser we've "consumed" so we can detect
                // contribs returned for names we didn't expect (extra renames,
                // API quirks). Keys we know about up-front: the api-canonical
                // name of every input in this batch.
                var consumedKeys = new Set();

                // Iterate users in input order
                for (var u = 0; u < batch.length; u++) {
                    if (cancelRequested) { cancelledMidRun = true; break outer; }

                    var user = batch[u];
                    processedUsers++;
                    var inputCanon = normalizeUsername(user);
                    var apiCanon = apiNameByInputCanonical.get(inputCanon) || inputCanon;
                    // Try the api-canonical name first (handles renamed users
                    // where c.user is the new name), then fall back to the
                    // input name in case the rename mapping wasn't available.
                    var contribs = byUser.get(apiCanon);
                    if (contribs == null) contribs = byUser.get(inputCanon);
                    if (contribs == null) contribs = [];
                    consumedKeys.add(apiCanon);
                    consumedKeys.add(inputCanon);

                    var perUserHeader = '[' + processedUsers + '/' + usernames.length + '] ' +
                                        user + ' - ' + contribs.length + ' edit(s)';
                    setStatus(perUserHeader);
                    logLine(perUserHeader);
                    updateProgress(user, 0, contribs.length);

                    // Build absolute URLs without hardcoding /wiki/ or /w/index.php,
                    // so the script also works on wikis with custom $wgArticlePath
                    // or $wgScriptPath.
                    var contribsUrl = location.origin + mw.util.getUrl('Special:Contributions/' + user);

                    w('\n' + '='.repeat(80) + '\n');
                    w('User: ' + user + '\n');
                    if (apiCanon !== inputCanon) {
                        w('Current name (after rename): ' + apiCanon + '\n');
                    }
                    w('Contribs: ' + contribsUrl + '\n');
                    w('='.repeat(80) + '\n');

                    if (contribs.length === 0) {
                        w('(no contributions found - account may be hidden, renamed, or never edited)\n');
                        continue;
                    }

                    w('Total contributions: ' + contribs.length + '\n\n');

                    for (var i = 0; i < contribs.length; i++) {
                        if (cancelRequested) { cancelledMidRun = true; break outer; }

                        var c = contribs[i];
                        var diffUrl = location.origin + INDEX_SCRIPT + '?diff=' + c.revid;

                        w('--- Edit ' + (i + 1) + '/' + contribs.length + ' ---\n');
                        w('Timestamp:    ' + c.timestamp + '\n');
                        w('Article:      ' + c.title + '\n');
                        w('Diff URL:     ' + diffUrl + '\n');
                        w('Revision ID:  ' + c.revid + ' (parent: ' + (c.parentid || 0) + ')\n');
                        var sd = (c.sizediff != null) ? (c.sizediff > 0 ? '+' + c.sizediff : c.sizediff) : '?';
                        w('Size change:  ' + sd + '\n');
                        if (c.flags && c.flags.length) w('Flags:        ' + c.flags.join(', ') + '\n');

                        if (includeSummary) {
                            if (c.commenthidden !== undefined) {
                                w('Edit summary: (hidden / revdel)\n');
                            } else if (c.comment != null && c.comment !== '') {
                                w('Edit summary: ' + oneLine(c.comment) + '\n');
                            } else if (c.comment === '') {
                                w('Edit summary: (empty)\n');
                            } else {
                                w('Edit summary: (none)\n');
                            }
                        }

                        if (includeDiff) {
                            // Wrap both the sleep and the getDiff call in a
                            // single try/catch. A cancellation during the
                            // delay must set cancelledMidRun and break out
                            // cleanly so the partial report still gets
                            // written and downloaded.
                            try {
                                await sleep(DELAY_MS);
                                var diff = await getDiff(c.revid, c.parentid);
                                w('\nDiff:\n' + diff + '\n');
                            } catch (e) {
                                if (e.message === 'cancelled') { cancelledMidRun = true; break outer; }
                                w('\nDiff:\n(fetch error: ' + e.message + ')\n');
                            }
                        }

                        w('\n');

                        editsDone++;
                        updateProgress(user, i + 1, contribs.length);
                    }
                }

                // Surface contribs returned for keys we never iterated. This
                // catches rename mappings the API didn't give us, or any other
                // case where c.user differs from both the input name and the
                // api-reported canonical name.
                byUser.forEach(function (arr, key) {
                    if (consumedKeys.has(key)) return;
                    if (!arr || arr.length === 0) return;
                    w('\n' + '='.repeat(80) + '\n');
                    w('Unmatched contribs returned for: ' + key + '\n');
                    w('(this name was not in the input list - possibly a renamed account whose current name was not detected)\n');
                    w('='.repeat(80) + '\n');
                    w('Total contributions: ' + arr.length + '\n\n');
                    for (var ui = 0; ui < arr.length; ui++) {
                        var uc = arr[ui];
                        w('--- Edit ' + (ui + 1) + '/' + arr.length + ' ---\n');
                        w('Timestamp:    ' + uc.timestamp + '\n');
                        w('Article:      ' + uc.title + '\n');
                        w('Diff URL:     ' + location.origin + INDEX_SCRIPT + '?diff=' + uc.revid + '\n');
                        w('Revision ID:  ' + uc.revid + ' (parent: ' + (uc.parentid || 0) + ')\n');
                        if (includeSummary) {
                            if (uc.commenthidden !== undefined) {
                                w('Edit summary: (hidden / revdel)\n');
                            } else if (uc.comment != null && uc.comment !== '') {
                                w('Edit summary: ' + oneLine(uc.comment) + '\n');
                            }
                        }
                        w('\n');
                    }
                    logLine('Note: ' + arr.length + ' contribs returned under unexpected name "' + key + '"');
                });

                // Small breather between batches
                if (bi < batches.length - 1 && !cancelRequested) {
                    try { await sleep(DELAY_MS); }
                    catch (e) { if (e.message === 'cancelled') { cancelledMidRun = true; break; } else throw e; }
                }
            }

            // Trigger download. Pass the fragment array directly to Blob -
            // BlobPart[] is part of the Blob spec, so this avoids the peak
            // memory doubling that out.join('') would cause on large reports.
            var blob = new Blob(out, { type: 'text/plain;charset=utf-8' });
            // Drop our reference to the fragment array so the GC can reclaim
            // it while the download is being saved.
            out = null;
            var url = URL.createObjectURL(blob);
            var a = document.createElement('a');
            a.href = url;
            a.download = 'contribs_report_' + new Date().toISOString().replace(/[:.]/g, '-') + '.txt';
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);

            setStatus(cancelledMidRun ? 'Cancelled. Partial file downloaded.' : 'Done. File downloaded.');
            logLine(cancelledMidRun ? 'Cancelled. Partial file downloaded.' : 'Done.');
        } catch (e) {
            if (e.message === 'cancelled') {
                setStatus('Cancelled.');
                logLine('Cancelled by user.');
            } else {
                setStatus('Error: ' + e.message);
                logLine('Error: ' + e.message);
            }
        } finally {
            running = false;
            cancelRequested = false;
            activeAbortControllers.clear();
            sleepRejecters.clear();
            $runBtn.prop('disabled', false);
            $stopBtn.prop('disabled', true);
        }
    }

    $runBtn.on('click', run);
    $stopBtn.on('click', function () {
        if (running) {
            cancelRequested = true;
            setStatus('Cancelling...');
            abortAllInFlight();
        }
    });
});
// </nowiki>