Jump to content

User:Polygnotus/Scripts/RFCTracker.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.
/**
 * RfC Tracker - shows up to 5 newly-opened RfCs in the sidebar.
 * "New" means: the parent revision did not yet carry the RfC tag.
 * Uses the Page Visibility API to refresh when the tab becomes visible.
 * Requires: [[User:Polygnotus/Helpers/Sidebar.js]]
 * Install: paste into [[Special:MyPage/common.js]]
 */
( function () {
    'use strict';

    // Refuse to run without the Page Visibility API (also used below for refresh)
    if ( typeof document.hidden === 'undefined' ) {
        return;
    }

    var API_ENDPOINT = '/w/api.php';
    var helper;

    /* ------------------------------------------------------------------ */
    /*  Fetch                                                               */
    /* ------------------------------------------------------------------ */

    // Generation counter — incremented before every fetch.
    // Each XHR callback closes over its own generation value and bails out
    // if a newer fetch has since started, preventing stale results from
    // overwriting fresher ones after rapid tab switching.
    var fetchGeneration = 0;

    function fetchRfCs() {
        var generation = ++fetchGeneration;

        helper.setHeadingLabel( 'Recent RfCs (…)' );

        // Edits and new pages: an RfC can be placed on a freshly-created talk
        // page (type=new) or added to an existing page (type=edit).
        // Fetch 50 so that filtering still leaves us with 5.
        var params = new URLSearchParams( {
            action:  'query',
            list:    'recentchanges',
            rctag:   'RfC',
            rclimit: '50',
            rcprop:  'title|timestamp|user|ids',
            rctype:  'edit|new',
            rcshow:  '!bot',
            format:  'json',
            origin:  '*'
        } );

        var xhr = new XMLHttpRequest();
        xhr.open( 'GET', API_ENDPOINT + '?' + params.toString(), true );
        xhr.setRequestHeader( 'Api-User-Agent', 'RfCTrackerUserscript/1.0 (common.js gadget)' );
        xhr.onreadystatechange = function () {
            if ( xhr.readyState !== 4 ) { return; }
            if ( generation !== fetchGeneration ) { return; } // stale response — discard
            if ( xhr.status !== 200 ) {
                helper.setHeadingLabel( 'Recent RfCs (error)' );
                return;
            }
            try {
                var data    = JSON.parse( xhr.responseText );
                var changes = data.query && data.query.recentchanges;
                if ( !changes ) { throw new Error( 'Bad response' ); }
                filterNewRfCs( changes, generation );
            } catch ( e ) {
                helper.setHeadingLabel( 'Recent RfCs (error)' );
            }
        };
        xhr.send( null );
    }

    /**
     * Sort a copy of changes newest-first, then remove duplicate page titles,
     * keeping the first (most recent) occurrence.
     * Sorting internally makes this function self-contained and not dependent
     * on the caller providing pre-sorted input.
     */
    function deduplicateByTitle( changes ) {
        var sorted = changes.slice().sort( function ( a, b ) {
            return new Date( b.timestamp ) - new Date( a.timestamp );
        } );
        var seen = {};
        return sorted.filter( function ( rc ) {
            if ( seen[ rc.title ] ) { return false; }
            seen[ rc.title ] = true;
            return true;
        } );
    }

    /**
     * Keep entries where the RfC tag was introduced by this revision.
     * - type === 'new': page was just created (e.g. a new talk page) with an
     *   RfC already on it, so the RfC is always new by definition.
     * - type === 'edit': check the parent revision; skip if it already carried
     *   the RfC tag (meaning the RfC predates this edit).
     * After filtering, truncate to 5 results.
     */
    function filterNewRfCs( changes, generation ) {
        var newPages = changes.filter( function ( rc ) { return rc.type === 'new'; } );
        var edits    = changes.filter( function ( rc ) { return rc.type === 'edit'; } );

        if ( edits.length === 0 ) {
            helper.markDataLoaded();
            renderRows( deduplicateByTitle( newPages ).slice( 0, 5 ) );
            return;
        }

        // Slice edits to 49 before deriving oldRevIds so that introducingEdits
        // is filtered against the same subset. Without this, edits beyond
        // index 48 would have parent revids never sent to the API, causing them
        // to pass the filter incorrectly. MediaWiki's revids limit is 50;
        // staying at 49 leaves room for edge cases.
        var editsToCheck = edits.slice( 0, 49 );

        var oldRevIds = editsToCheck
            .map( function ( rc ) { return rc.old_revid; } )
            .filter( Boolean );

        if ( oldRevIds.length === 0 ) {
            helper.markDataLoaded();
            renderRows( deduplicateByTitle( newPages ).slice( 0, 5 ) );
            return;
        }

        var params = new URLSearchParams( {
            action:  'query',
            prop:    'revisions',
            revids:  oldRevIds.join( '|' ),
            rvprop:  'ids|tags',
            format:  'json',
            origin:  '*'
        } );

        var xhr = new XMLHttpRequest();
        xhr.open( 'GET', API_ENDPOINT + '?' + params.toString(), true );
        xhr.setRequestHeader( 'Api-User-Agent', 'RfCTrackerUserscript/1.0 (common.js gadget)' );
        xhr.onreadystatechange = function () {
            if ( xhr.readyState !== 4 ) { return; }
            if ( generation !== fetchGeneration ) { return; } // stale response — discard
            if ( xhr.status !== 200 ) {
                helper.setHeadingLabel( 'Recent RfCs (error)' );
                return;
            }
            try {
                var data  = JSON.parse( xhr.responseText );
                var pages = data.query && data.query.pages;
                if ( !pages ) { throw new Error( 'Bad response' ); }

                // Build a set of parent revids that already carried the RfC tag
                var parentHadRfC = {};
                Object.keys( pages ).forEach( function ( pageId ) {
                    var revs = pages[ pageId ].revisions || [];
                    revs.forEach( function ( rev ) {
                        if ( rev.tags && rev.tags.indexOf( 'RfC' ) !== -1 ) {
                            parentHadRfC[ rev.revid ] = true;
                        }
                    } );
                } );

                // Keep only edits (from the checked subset) whose parent did
                // NOT have the RfC tag
                var introducingEdits = editsToCheck.filter( function ( rc ) {
                    return !parentHadRfC[ rc.old_revid ];
                } );

                var combined = newPages.concat( introducingEdits );
                combined.sort( function ( a, b ) {
                    return new Date( b.timestamp ) - new Date( a.timestamp );
                } );

                helper.markDataLoaded();
                renderRows( deduplicateByTitle( combined ).slice( 0, 5 ) );
            } catch ( e ) {
                helper.setHeadingLabel( 'Recent RfCs (error)' );
            }
        };
        xhr.send( null );
    }

    /* ------------------------------------------------------------------ */
    /*  Render                                                              */
    /* ------------------------------------------------------------------ */
    function renderRows( changes ) {
        var ul = document.createElement( 'ul' );
        ul.className = 'vector-menu-content-list';

        if ( changes.length === 0 ) {
            var empty     = document.createElement( 'li' );
            empty.className = 'mw-list-item';
            var emptySpan = document.createElement( 'span' );
            emptySpan.textContent = 'No results.';
            empty.appendChild( emptySpan );
            ul.appendChild( empty );
        } else {
            changes.forEach( function ( rc ) {
                var li = document.createElement( 'li' );
                li.className = 'mw-list-item';

                var articleLink = document.createElement( 'a' );
                // mw.util.getUrl handles all title encoding correctly,
                // including titles that contain literal '%' characters which
                // encodeURIComponent() would double-encode.
                articleLink.href = mw.util.getUrl( rc.title );
                var articleSpan  = document.createElement( 'span' );
                articleSpan.textContent = rc.title;
                articleLink.appendChild( articleSpan );
                li.appendChild( articleLink );

                var diffLink = document.createElement( 'a' );
                diffLink.href          = '/w/index.php?diff=' + rc.revid;
                diffLink.style.marginLeft = '4px';
                var diffSpan           = document.createElement( 'span' );
                diffSpan.textContent   = '(diff)';
                diffLink.appendChild( diffSpan );
                li.appendChild( diffLink );

                var meta            = document.createElement( 'div' );
                meta.style.fontSize = 'x-small';
                meta.style.color    = '#555';
                meta.textContent    = new Date( rc.timestamp ).toLocaleString() + ' · ' + rc.user;
                li.appendChild( meta );

                ul.appendChild( li );
            } );
        }

        helper.replaceRows( ul );
        helper.setHeadingLabel( 'Recent RfCs' );
    }

    /* ------------------------------------------------------------------ */
    /*  Page Visibility API - refresh when tab becomes visible             */
    /* ------------------------------------------------------------------ */
    // Registered inside $(function(){}) so the widget shell is guaranteed to
    // exist in the DOM before the first visibilitychange event can fire and
    // call helper.setHeadingLabel(). Attaching it here (outside DOM ready)
    // would cause setHeadingLabel() to silently no-op if the event fired
    // before the shell was inserted.
    function attachVisibilityListener() {
        document.addEventListener( 'visibilitychange', function () {
            if ( !document.hidden ) {
                fetchRfCs();
                // Incrementing fetchGeneration inside fetchRfCs() automatically
                // cancels any in-flight request pair from a previous visibility
                // event, so no additional debouncing is needed here.
            }
        } );
    }

    /* ------------------------------------------------------------------ */
    /*  Init                                                                */
    /* ------------------------------------------------------------------ */
    mw.loader.using( 'mediawiki.util' ).then( function () {
        mw.loader.getScript(
            'https://en.wikipedia.org/w/index.php?title=User:Polygnotus/Helpers/Sidebar.js&action=raw&ctype=text/javascript'
        ).then( function () {
            helper = window.SidebarHelper( {
                id         : 'p-rfc-tracker',
                storageKey : 'rfcTrackerCollapsed',
                heading    : 'Recent RfCs',
                btnClass   : 'rfc-collapse-btn',
                onExpand   : fetchRfCs
            } );

            $( function () {
                // Render the shell so the widget exists in the DOM before
                // fetchRfCs() calls helper.setHeadingLabel('…') or the
                // error handler calls it with 'error'.
                renderRows( [] );
                if ( !helper.isCollapsed() ) {
                    fetchRfCs();
                }
                // Attach after the shell is in the DOM so setHeadingLabel()
                // always finds the widget when a visibilitychange event fires.
                attachVisibilityListener();
            } );

        }, function () {
            mw.log.error( 'RfC Tracker: failed to load SidebarHelper.' );
        } );
    } );

}() );