User:Enterprisey/section-watchlist.js

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.
// vim: ts=4 sw=4 et
$.when( mw.loader.using( [ "mediawiki.api" ] ), $.ready ).then( function () {
    var api = new mw.Api();
    var PARSOID_ENDPOINT = "https:" + mw.config.get( "wgServer" ) + "/api/rest_v1/page/html/";
    var HEADER_REGEX = /^\s*=(=*)\s*(.+?)\s*\1=\s*$/gm;
    var BACKEND_URL = "https://section-watchlist.toolforge.org";
    var TOKEN_OPTION_NAME = "userjs-section-watchlist-token";
    var LOCAL_STORAGE_PREFIX = "wikipedia-section-watchlist-";
    var LOCAL_STORAGE_PAGE_LIST_KEY = LOCAL_STORAGE_PREFIX + "page-list";
    var LOCAL_STORAGE_EXPIRY_KEY = LOCAL_STORAGE_PREFIX + "expiry";
    var PAGE_LIST_EXPIRY_MILLIS = 7 * 24 * 60 * 60 * 1000; // a week

    var ENTERPRISEY_ENWP_TALK_PAGE_LINK = '<a href="https://en.wikipedia.org/wiki/User talk:Enterprisey/section-watchlist" title="User talk:Enterprisey/section-watchlist on the English Wikipedia">User talk:Enterprisey/section-watchlist</a>';
    var CORS_ERROR_MESSAGE = 'Error contacting the server. It might be down, in which case ' + ENTERPRISEY_ENWP_TALK_PAGE_LINK + ' (en.wiki) will have updates.';

    /////////////////////////////////////////////////////////////////
    //
    // Utilities

    // Polyfill from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes
    if( !String.prototype.includes ) {
        String.prototype.includes = function( search, start ) {
            if( search instanceof RegExp ) {
                throw TypeError('first argument must not be a RegExp');
            }
            if( start === undefined ) {
                start = 0;
            }
            return this.indexOf( search, start ) !== -1;
        };
    }

    // Polyfill from https://github.com/jonathantneal/array-flat-polyfill, which is CC0-licensed
    if( !Array.prototype.flat ) {
        Object.defineProperty( Array.prototype, 'flat', {
            configurable: true,
            value: function flat () {
                var depth = isNaN( arguments[0] ) ? 1 : Number( arguments[0] );

                return depth ? Array.prototype.reduce.call( this, function ( acc, cur ) {
                    if( Array.isArray( cur ) ) {
                        acc.push.apply( acc, flat.call( cur, depth - 1 ) );
                    } else {
                        acc.push( cur );
                    }

                    return acc;
                }, [] ) : Array.prototype.slice.call( this );
            },
            writable: true
        } );
    }

    // https://stackoverflow.com/a/9229821/1757964
    function removeDuplicates( array ) {
        var seen = {};
        return array.filter( function( item ) {
            return seen.hasOwnProperty( item ) ? false : ( seen[ item ] = true );
        } );
    }

    function lastInArray( array ) {
        return array[ array.length - 1 ];
    }

    function pageNameOfHeader( header ) {
        var editLinks = Array.prototype.slice.call( header.querySelectorAll( "a" ) )
            .filter( function ( e ) { return e.textContent.indexOf( "edit" ) === 0; } );
        if( editLinks.length ) {
            var encoded = editLinks[0]
                .getAttribute( "href" )
                .match( /title=(.+?)(?:$|&)/ )
                [1];
            return decodeURIComponent( encoded ).replace( /_/g, " " );
        } else {
            return null;
        }
    }

    var getAllTranscludedTitlesCache = null;
    function getAllTranscludedTitles() {
        if( !getAllTranscludedTitlesCache ) {
            var allHeadersArray = Array.prototype.slice.call(
                document.querySelector( "#mw-content-text" ).querySelectorAll( "h1,h2,h3,h4,h5,h6" ) );
            getAllTranscludedTitlesCache = removeDuplicates( allHeadersArray
                .filter( function ( header ) {
                    // The word "Contents" at the top of the table of contents is a heading
                    return header.getAttribute( "id" ) !== "mw-toc-heading"
                } )
                .map( pageNameOfHeader )
                .filter( Boolean ) );
        }
        return getAllTranscludedTitlesCache;
    }

    /////////////////////////////////////////////////////////////////
    //
    // User interface for normal pages

    function loadPagesWatched() {
        try {
            var expiryStr = window.localStorage.getItem(LOCAL_STORAGE_EXPIRY_KEY);
            if( expiryStr ) {
                var expiry = parseInt( expiryStr );
                if( expiry && ( ( new Date().getTime() - expiry ) < PAGE_LIST_EXPIRY_MILLIS ) ) {
                    var list = window.localStorage.getItem(LOCAL_STORAGE_PAGE_LIST_KEY);
                    return $.when( { status: "success", data: list.split( "," ) } );
                }
            }

            var url = BACKEND_URL + "/subbed_pages?user_id=" +
                mw.config.get( "wgUserId" ) + "&token=" + mw.user.options.get( TOKEN_OPTION_NAME );
            return $.getJSON( url ).then( function ( data ) {
                if( data.status === "success" ) {
                    try {
                        window.localStorage.setItem(LOCAL_STORAGE_EXPIRY_KEY, new Date().getTime());
                        window.localStorage.setItem(LOCAL_STORAGE_PAGE_LIST_KEY, data.data.join( "," ));
                    } catch ( e ) {
                        console.error( e );
                    }
                }
                return data;
            } );
        } catch ( e ) {
            console.error( e );
        }
    }

    function loadSectionsWatched( allTranscludedIds ) {
        var promises = allTranscludedIds.map( function ( id ) {
            return $.getJSON( BACKEND_URL + "/subbed_sections?page_id=" +
                id + "&user_id=" +
                mw.config.get( "wgUserId" ) + "&token=" + mw.user.options.get( TOKEN_OPTION_NAME ) );
        } );
        return $.when.apply( $, promises ).then( function () {
            var obj = {};
            if( allTranscludedIds.length === 1 ) {
                if( arguments[0].status === "success" ) {
                    obj[allTranscludedIds[0]] = arguments[0].data;
                    return { status: "success", data: obj };
                } else {
                    return arguments[0];
                }
            } else {
                var groupStatus = "";
                var errorMessage = null;
                for( var i = 0; i < arguments.length; i++ ) {
                    if( arguments[i][0].status !== "success" ) {
                        allSuccess = false;
                        errorMessage = arguments[i][0].data;
                    } else {
                        obj[allTranscludedIds[i]] = arguments[i][0].data;
                    }
                    if( groupStatus === "success" ) {
                        groupStatus = arguments[i][0].status;
                    }
                }
                return {
                    status: groupStatus,
                    data: ( groupStatus === "success" ) ? obj : errorMessage
                };
            }
        } );
    }

    function initializeFakeLinks( messageHtml ) {
        mw.loader.using( [ "mediawiki.util", "oojs-ui-core", "oojs-ui-widgets" ] );
        $( "#mw-content-text" ).find( "h1,h2,h3,h4,h5,h6" ).each( function ( idx, header ) {
            var popup = null;
            $( header ).find( ".mw-editsection *" ).last().before(
                "<span style='color: #54595d'> | </span>",
                $( "<span>" ).append(
                    $( "<a>" )
                        .attr( "href", "#" )
                        .text( "watch" )
                        .click( function () {
                            if( popup === null ) {
                                mw.loader.using( [ "mediawiki.util", "oojs-ui-core", "oojs-ui-widgets" ] ).then( function () {
                                    popup = new OO.ui.PopupWidget( {
                                        $content: $( '<p>', { style: 'padding-top: 0.5em' } ).html( messageHtml ),
                                        padded: true,
                                        width: 400,
                                        align: 'forwards',
                                        hideCloseButton: false,
                                    } );
                                    $( this ).parent().append( popup.$element );
                                    popup.toggle( true );
                                }.bind( this ) );
                            } else {
                                popup.toggle();
                            }
                            return false;
                        } ) ) );
        } );
    }

    function attachLink( header, pageId, pageName, wikitextName, dupIdx, isAlreadyWatched ) {
        $( header ).find( ".mw-editsection *" ).last().before(
            "<span style='color: #54595d'> | </span>",
            $( "<a>" )
                .attr( "href", "#" )
                .text( isAlreadyWatched ? "unwatch" : "watch" )
                .click( function () {
                    var link = $( this );
                    if( !mw.user.options.get( TOKEN_OPTION_NAME ) ) {
                        alert( "You must register first by visiting Special:BlankPage/section-watchlist." );
                        return false;
                    }
                    var data = {
                        page_id: pageId,
                        page_title: pageName,
                        section_name: wikitextName,
                        section_dup_idx: dupIdx,
                        user_id: mw.config.get( "wgUserId" ),
                        token: mw.user.options.get( TOKEN_OPTION_NAME )
                    };
                    if( this.textContent === "watch" ) {
                        $.post( BACKEND_URL + "/sub", data ).then( function ( data2 ) {
                            if( data2.status === "success" ) {
                                link.text( "unwatch" );
                                try {
                                    var list = window.localStorage.getItem( LOCAL_STORAGE_PAGE_LIST_KEY ) || "";
                                    if( !list.includes( pageId ) ) {
                                        window.localStorage.setItem( LOCAL_STORAGE_PAGE_LIST_KEY, list + "," + pageId );
                                    }
                                } catch ( e ) {
                                    console.error( e );
                                }
                            } else {
                                console.error( data2 );
                            }
                        }, function ( request ) {
                            if( request.responseJSON && request.responseJSON.status ) {
                                console.error( request.responseJSON );
                            }
                            console.error( request );
                        } );
                    } else {
                        $.post( BACKEND_URL + "/unsub", data ).then( function ( data2 ) {
                            if( data2.status === "success" ) {
                                link.text( "watch" );
                            } else {
                                console.error( data2 );
                            }
                        }, function ( request ) {
                            if( request.responseJSON && request.responseJSON.status ) {
                                console.error( request.responseJSON );
                            }
                            console.error( request );
                        } );
                    }
                    return false;
                } ) );
    }

    function initializeLinks( transcludedTitlesAndIds, allWatchedSections ) {

        var allHeadersArray = Array.prototype.slice.call(
            document.querySelector( "#mw-content-text" ).querySelectorAll( "h1,h2,h3,h4,h5,h6" ) );
        var allHeaders = allHeadersArray
            .filter( function ( header ) {
                // The word "Contents" at the top of the table of contents is a heading
                return header.getAttribute( "id" ) !== "mw-toc-heading"
            } )
            .map( function ( header ) {
                return [ header, pageNameOfHeader( header ) ];
            } )
            .filter( function ( headerAndPage ) {
                return headerAndPage[1] !== null
            } );
        var allTranscludedTitles = removeDuplicates( allHeaders.map( function ( header ) { return header[1]; } ) );

        return api.get( {
            action: "query",
            prop: "revisions",
            titles: allTranscludedTitles.join("|"),
            rvprop: "content",
            rvslots: "main",
            formatversion: 2
        } ).then( function( revData ) {
            for( var pageIdx = 0; pageIdx < revData.query.pages.length; pageIdx++ ) {
                var targetTitle = revData.query.pages[pageIdx].title;
                var targetPageId = revData.query.pages[pageIdx].pageid;
                var targetWikitext = revData.query.pages[pageIdx].revisions[0].slots.main.content;
                var watchedSections = allWatchedSections ? allWatchedSections[targetPageId] : {};

                var allHeadersFromTarget = allHeaders.filter( function ( header ) { return header[1] === targetTitle; } );

                // Find all the headers in the wikitext

                // (Nowiki exclusion code copied straight from reply-link)
                // Save all nowiki spans
                var nowikiSpanStarts = []; // list of ignored span beginnings
                var nowikiSpanLengths = []; // list of ignored span lengths
                var NOWIKI_RE = /<(nowiki|pre)>[\s\S]*?<\/\1>/g;
                var spanMatch;
                do {
                    spanMatch = NOWIKI_RE.exec( targetWikitext );
                    if( spanMatch ) {
                        nowikiSpanStarts.push( spanMatch.index );
                        nowikiSpanLengths.push( spanMatch[0].length );
                    }
                } while( spanMatch );

                // So that we don't check every ignore span every time
                var nowikiSpanStartIdx = 0;

                var headerMatches = [];
                var headerMatch;
                matchLoop:
                do {
                    headerMatch = HEADER_REGEX.exec( targetWikitext );
                    if( headerMatch ) {

                        // Check that we're not inside a nowiki
                        for( var nwIdx = nowikiSpanStartIdx; nwIdx <
                            nowikiSpanStarts.length; nwIdx++ ) {
                            if( headerMatch.index > nowikiSpanStarts[nwIdx] ) {
                                if ( headerMatch.index + headerMatch[0].length <=
                                    nowikiSpanStarts[nwIdx] + nowikiSpanLengths[nwIdx] ) {

                                    // Invalid sig
                                    continue matchLoop;
                                } else {

                                    // We'll never encounter this span again, since
                                    // headers only get later and later in the wikitext
                                    nowikiSpanStartIdx = nwIdx;
                                }
                            }
                        }
                        headerMatches.push( headerMatch );
                    }
                } while( headerMatch );

                // We'll use this dictionary to calculate the duplicate index
                var headersByText = {};
                for( var i = 0; i < headerMatches.length; i++ ) {

                    // Group 2 of HEADER_REGEX is the header text
                    var text = headerMatches[i][2];
                    headersByText[text] = ( headersByText[text] || [] ).concat( i );
                }

                // allHeadersFromTarget should contain every header we found in the wikitext
                // (and more, if targetPageName was transcluded multiple times)
                if( allHeadersFromTarget.length % headerMatches.length !== 0 ) {
                    console.error(allHeadersFromTarget);
                    console.error(headerMatches);
                    throw new Error( "non-divisble header list lengths" );
                }

                for( var headerIdx = 0; headerIdx < allHeadersFromTarget.length; headerIdx++ ) {
                    var trueHeaderIdx = headerIdx % headerMatches.length;
                    var headerText = headerMatches[trueHeaderIdx][2];

                    // NOTE! The duplicate index is calculated relative to the
                    // *wikitext* header matches (because that's how the backend
                    // does it)! That is, if we have a page that includes two
                    // headers, both called "a", and we transclude that page
                    // twice, the result will be four headers called "a". But we
                    // want to assign those four headers, respectively, the
                    // duplicate indices of 0, 1, 0, 1. That's why we use
                    // trueHeaderIdx here, not headerIdx.
                    var dupIdx = headersByText[headerText].indexOf( trueHeaderIdx );

                    var headerEl = allHeadersFromTarget[headerIdx];
                    var headerId = headerEl[0].querySelector( "span.mw-headline" ).id;

                    var isAlreadyWatched = ( watchedSections[headerText] || [] ).indexOf( dupIdx ) >= 0;

                    attachLink( headerEl, targetPageId, targetTitle, headerText, dupIdx, isAlreadyWatched );
                }
            }
        }, function () {
            console.error( arguments );
        } );
    }

    /////////////////////////////////////////////////////////////////
    //
    // The watchlist page

    function parseSimpleAddition( diffHtml ) {
        var CONTEXT_ROW = /<tr>\n  <td class="diff-marker">&#160;<\/td>\n  <td class="diff-context">(?:<div>([^<]*)<\/div>)?<\/td>\n  <td class="diff-marker">&#160;<\/td>\n  <td class="diff-context">.*?<\/td>\n<\/tr>\n/g;
        var ADDED_ROW = /<tr>\n  <td colspan="2" class="diff-empty">&#160;<\/td>\n  <td class="diff-marker">\+<\/td>\n  <td class="diff-addedline">(?:<div>([^<]*)<\/div>)?<\/td>\n<\/tr>\n/g;

        function consecutiveMatches( regex, text ) {
            var prevMatchEndIdx = null;
            var match = null;
            var rows = [];
            while( ( match = regex.exec( text ) ) !== null ) {
                if( ( prevMatchEndIdx !== null ) && ( prevMatchEndIdx !== match.index ) ) {
                    // this match wasn't immediately after the previous one
                    break;
                }
                rows.push( match[1] || "" );
                prevMatchEndIdx = match.index + match[0].length;
            }
            return {
                text: rows.join( "\n" ),
                endIdx: prevMatchEndIdx
            };
        }

        var prevContext = consecutiveMatches( CONTEXT_ROW, diffHtml );
        var added = consecutiveMatches( ADDED_ROW, diffHtml.substring( prevContext.endIdx ) );

        function fix( text ) {
            var INS_DEL = /<ins class="diffchange diffchange-inline">|<\/ins>|<del class="diffchange diffchange-inline">|<\/del>/g;
            var ENTITIES = /&(lt|gt|amp);/g;
            return text.replace( INS_DEL, "" ).replace( ENTITIES, function ( _match, group1 ) {
                switch( group1 ) {
                    case "lt": return "<";
                    case "gt": return ">";
                    case "amp": return "&";
                }
            } );
        }

        return {
            prevContext: fix( prevContext.text ),
            added: fix( added.text )
        };
    }

    function handleViewNewText( listElement, streamEvent, sectionEvent ) {
        api.get( {
            action: "compare",
            fromrev: streamEvent.data.revision["new"],
            torelative: "prev",
            formatversion: "2",
            prop: "diff"
        } ).then( function ( compareResponse ) {
            var diffHtml = compareResponse.compare.body;
            var parsedDiff = parseSimpleAddition( diffHtml );
            var addedHtmlPromise = $.post( {
                url: "https:" + mw.config.get( "wgServer" ) + "/w/api.php",
                data: {
                    action: "parse",
                    format: "json",
                    formatversion: "2",
                    title: streamEvent.title,
                    text: parsedDiff.added,
                    prop: "text", // just wikitext, please
                    pst: "1" // do the pre-save transform
                }
            } );

            var listElementAddedPromise = addedHtmlPromise.then( function ( newHtmlResponse ) {
                listElement.append( newHtmlResponse.parse.text );
                var newContent = listElement.find( ".mw-parser-output" );
                mw.hook( "wikipage.content" ).fire( $( newContent ) );
            } );

            var revObjPromise = api.get( {
                action: "query",
                prop: "revisions",
                rvprop: "timestamp|content|ids",
                rvslots: "main",
                rvlimit: 1,
                titles: streamEvent.title,
                formatversion: 2,
            } ).then( function ( data ) {
                if( data.query.pages[0].revisions ) {
                    var rev = data.query.pages[0].revisions[0];
                    return { revId: rev.revid, timestamp: rev.timestamp, content: rev.slots.main.content };
                } else {
                    console.error( data );
                    throw new Error( "[getWikitext] bad response: " + data );
                }
            } );

            $.when(
                addedHtmlPromise,
                revObjPromise,
                listElementAddedPromise
            ).then( function ( newHtmlResponse, revObj, _ ) {

                // Walmart reply-link
                var namespace = streamEvent.namespace;
                var ttdykPage = streamEvent.title.indexOf( "Template:Did_you_know_nominations" ) === 0;
                if( ( namespace % 2 ) === 1 || namespace === 4 || ttdykPage ) {
                    // Ideally this is kept in sync with the one defined
                    // near the top of reply-link; if they differ, I imagine
                    // the reply-link one is correct
                    var REPLY_LINK_TIMESTAMP_REGEX = /\(UTC(?:(?:−|\+)\d+?(?:\.\d+)?)?\)\S*?\s*$/m;
                    var newContent = listElement.find( ".mw-parser-output" ).get( 0 );
                    if( REPLY_LINK_TIMESTAMP_REGEX.test( newContent.textContent ) ) {
                        var nodeToAttachAfter = newContent.children[0];
                        do {
                            nodeToAttachAfter = lastInArray( nodeToAttachAfter.childNodes );
                        } while( lastInArray( nodeToAttachAfter.childNodes ).nodeType !== 3 /* Text */ );
                        nodeToAttachAfter = lastInArray( nodeToAttachAfter.childNodes );
                        var parentCmtIndentation = /^[:*#]*/.exec( parsedDiff.added )[0];
                        var sectionName = sectionEvent.target[0].replace( /_/g, " " );
                        var headerRegex = new RegExp( "^=(=*)\\s*" + mw.util.escapeRegExp( sectionName ) + "\\s*\\1=\\s*$", "gm" );
                        var sectionDupIdx = sectionEvent.target[1];
                        for( var i = 0; i < sectionDupIdx; i++ ) {
                            // Advance the regex past all the previous duplicate matches
                            headerRegex.exec( revObj.content );
                        }
                        var headerMatch = headerRegex.exec( revObj.content );
                        var REPLY_LINK_HEADER_REGEX = /^\s*=(=*)\s*(.+?)\s*\1=\s*$/gm;
                        var endOfThatHeaderIdx = headerMatch.index + headerMatch[0].length;
                        var nextHeaderMatch = REPLY_LINK_HEADER_REGEX.exec( revObj.content.substring( endOfThatHeaderIdx ) );
                        var nextHeaderIdx = endOfThatHeaderIdx + ( nextHeaderMatch ? nextHeaderMatch.index : revObj.content.length );
                        var parentCmtEndStrIdx = revObj.content.indexOf( parsedDiff.prevContext ) +
                            parsedDiff.prevContext.length + parsedDiff.added.length - headerMatch.index;
                        mw.hook( "replylink.attachlinkafter" ).fire(
                            nodeToAttachAfter,
                            /* preferredId */ "",
                            /* parentCmtObj */ {
                                indentation: parentCmtIndentation,
                                sigIdx: null,
                                endStrIdx: parentCmtEndStrIdx
                            },
                            /* sectionObj */ {
                                title: sectionName,
                                dupIdx: sectionDupIdx,
                                startIdx: headerMatch.index,
                                endIdx: nextHeaderIdx,
                                idxInDomHeaders: null,
                                pageTitle: streamEvent.title.replace( /_/g, " " ),
                                revObj: revObj,
                                headerEl: null
                            }
                        );
                    } else {
                        console.warn( "text content didn't match timestamp regex" );
                    }
                } else {
                    console.warn( "bad namespace " + namespace );
                }
            } );
        } );
    }

    function renderLengthDiff( beforeLength, afterLength ) {
        var delta = afterLength - beforeLength;
        var el = ( Math.abs( delta ) > 500 ) ? "strong" : "span";
        var elClass = "mw-plusminus-" + ( ( delta > 0 ) ? "pos" : ( ( delta < 0 ) ? "neg" : "null" ) );
        return $( "<span>", { "class": "mw-changeslist-line-inner-characterDiff" } ).append(
            $( "<" + el + ">", {
                "class": elClass + " mw-diff-bytes",
                "dir": "ltr",
                "title": afterLength + " byte" + ( ( afterLength === 1 ) ? "" : "s" ) + " after change of this size"
            } ).text( ( ( delta > 0 ) ? "+" : "" ) + mw.language.convertNumber( delta ) ) );
    }

    function renderItem( streamEvent, sectionEvent ) {
        var url = mw.util.getUrl( streamEvent.title ) + "#" + sectionEvent.target[0];
        var els = [
            streamEvent.timestamp.substring( 8, 10 ) + ":" + streamEvent.timestamp.substring( 10, 12 ),
            $( "<span>", { "class": "mw-changeslist-line-inner-articleLink" } ).append(
                $( "<span>", { "class": "mw-title" } ).append(
                    $( "<a>", { "class": "mw-changeslist-title", "href": url, "title": streamEvent.title } )
                        .text( streamEvent.title + " § " + sectionEvent.target[0].replace( /_/g, " " ) ) ) ),
            // TODO pending support for "vague sections"
            //sectionEvent.target[2]
            //    ? $( "<span>" ).append( "(under ", $( "<a>", { "href": secondaryUrl } ).text( streamEvent.target[2][0] ) )
            //    : "",
            streamEvent.data.revision["new"]
                ? $( "<span>", { "class": "mw-changeslist-line-inner-historyLink" } ).append(
                    $( "<span>", { "class": "mw-changeslist-links" } ).append(
                        $( "<span>" ).append(

                            // The URL parameters must be in this order, or Navigation Popups will not work for this link. (UGH.)
                            $( "<a>", {
                                "class": "mw-changeslist-diff",
                                "href": mw.util.getUrl( "", {
                                    "title": streamEvent.title,
                                    "diff": "prev",
                                    "oldid": streamEvent.data.revision["new"]
                                } )
                            } ).text( "diff" ) ),
                        $( "<span>" ).append(
                            $( "<a>", { "class": "mw-changeslist-history", "href": mw.util.getUrl( streamEvent.title, { "action": "history" } ) } )
                                .text( "hist" ) ),
                        ) )
                : "",
            $( "<span>", { "class": "mw-changeslist-line-inner-separatorAfterLinks" } ).append(
                $( "<span>", { "class": "mw-changeslist-separator" } ) ),
            renderLengthDiff( streamEvent.data.length.old, streamEvent.data.length["new"] ),
            $( "<span>", { "class": "mw-changeslist-line-inner-separatorAftercharacterDiff" } ).append(
                $( "<span>", { "class": "mw-changeslist-separator" } ) ),
            $( "<span>", { "class": "mw-changeslist-line-inner-userLink" } ).append(
                $( "<a>", { "class": "mw-userlink", "href": mw.util.getUrl( "User:" + streamEvent.user ), "title": "User:" + streamEvent.user } ).append(
                    $( "<bdi>" ).text( streamEvent.user ) ) ),
            $( "<span>", { "class": "mw-changeslist-line-inner-userTalkLink" } ).append(
                $( "<span>", { "class": "mw-usertoollinks mw-changeslist-links" } ).append(
                    $( "<span>" ).append(
                        $( "<a>", { "class": "mw-usertoollinks-talk", "href": mw.util.getUrl( "User talk:" + streamEvent.user ), "title": "User talk:" + streamEvent.user } )
                            .text( "talk" ) ),
                    $( "<span>" ).append(
                        $( "<a>", { "class": "mw-usertoollinks-contribs", "href": mw.util.getUrl( "Special:Contributions/" + streamEvent.user ), "title": "Special:Contributions/" + streamEvent.user } )
                            .text( "contribs" ) ) ) ),
            streamEvent.data.minor
                ? $( "<abbr>", { "class": "minoredit", "title": "This is a minor edit" } ).text( "m" )
                : "",
            $( "<span>", { "class": "mw-changeslist-line-inner-comment" } ).append(
                $( "<span>", { "class": "comment comment--without-parentheses" } ).append(
                    $( "<span>", { "dir": "auto" } ).append( streamEvent.parsedcomment ) ) )
        ];
        if( streamEvent.data.is_simple_addition ) {
            els.push( $( "<span>" ).append( "(", $( "<a>", { "class": "section-watchlist-view-new-text", "href": "#" } ).text( "view new text" ), ")" ) );
        }
        for( var i = els.length - 1; i >= 0; i-- ) {
            els.splice( i, 0, " " );
        }
        return els;
    }

    function renderInbox( inbox ) {
        var days = [];
        var currDateString; // for example, the string "20200701", meaning "1 July 2020"
        var currItems = []; // the inbox entries for the current day, sorted from latest to earliest
        for( var i = 0; i < inbox.length; i++ ) {
            var streamEventAndSectionEvent = inbox[i];
            var streamEvent = streamEventAndSectionEvent.stream;
            var sectionEvent = streamEventAndSectionEvent.section;
            if( streamEvent.timestamp.substring( 0, 8 ) !== currDateString ) {
                if( currItems.length ) {
                    days.push( [ currDateString, currItems ] );
                }
                currItems = [];
                currDateString = streamEvent.timestamp.substring( 0, 8 );
            }
            if( sectionEvent.type === "Edit" ) {
                var sectionName = sectionEvent.target[0];
                var listEl = $( "<li>" ).append( renderItem( streamEvent, sectionEvent ) );
                if( streamEvent.data.is_simple_addition ) {
                    ( function () {
                        var currStreamEvent = streamEvent;
                        var currSectionEvent = sectionEvent;
                        listEl.find( ".section-watchlist-view-new-text" ).click( function ( evt ) {
                            var parserOutput = this.parentNode.parentNode.querySelector( ".mw-parser-output" );
                            if( parserOutput ) {
                                $( parserOutput ).toggle();
                            } else {
                                handleViewNewText( $( this ).parent().parent(), currStreamEvent, currSectionEvent );
                            }
                            if( this.textContent === "view new text" ) {
                                this.textContent = "hide new text";
                            } else {
                                this.textContent = "view new text";
                            }
                            evt.preventDefault();
                            return false;
                        } );
                    } )();
                }
                currItems.push( listEl );
            } else {
                currItems.push( $( "<li>" ).text( JSON.stringify( streamEvent ) + " | " + JSON.stringify( sectionEvent ) ) );
            }
        }
        if( currItems.length ) {
            days.push( [ currDateString, currItems ] );
        }
        return days;
    }

    // "20200701" -> "July 1" (in the user's interface language... approximately)
    // TODO there really has to be a better way to do this
    var englishMonths = [
        'january', 'february', 'march', 'april',
        'may', 'june', 'july', 'august',
        'september', 'october', 'november', 'december'
    ];
    function renderIsoDate( isoDate ) {
        return mw.msg( englishMonths[ parseInt( isoDate.substring( 4, 6 ) ) - 1 ] ) + " " + parseInt( isoDate.substring( 6, 8 ) );
    }

    // i.e. generate a message in the case that we have no token.
    function generateNoTokenMessage( registerUrl ) {
        return $.ajax( {
            type: "HEAD",
            "async": true,
            url: BACKEND_URL
        } ).then( function () {
            return 'You must register first by visiting <a href="' + registerUrl +
                '" title="The section-watchlist registration page">the registration page</a>.';
        }, function () {
            return 'The server is down. Check ' + ENTERPRISEY_ENWP_TALK_PAGE_LINK + ' for updates.';
        } );
    }

    // i.e. generate a message in the case that the backend gave us an error.
    function generateBackendErrorMessage( backendResponse, registerUrl ) {
        if( backendResponse.status === "bad_request" ) {
            switch( backendResponse.data ) {
                case "no_stored_token":
                    return "The system doesn't have a stored registration for your username. Please authenticate by visiting <a href='" + registerUrl + "' title='The section-watchlist registration page'>the registration page</a>.";
                case "bad_token":
                    return "Authentication failed. Please re-authenticate by visiting <a href='" +
                        registerUrl + "'>the registration page</a>.";
            }
        }
        return "Request failed (error: " + backendResponse.status + "/" + backendResponse.data +
            "). Re-authenticating by visiting <a href='" + registerUrl + "'>the registration page</a> may help.";
    }

    function makeBackendQuery( query_path, callback ) {
        var swtoken = mw.user.options.get( TOKEN_OPTION_NAME );
        var registerUrl = BACKEND_URL + "/oauth-register?user_id=" + mw.config.get( "wgUserId" );
        if( swtoken ) {
            $.getJSON( BACKEND_URL + query_path + "&token=" + swtoken ).then( function ( response ) {
                if( response.status === "success" ) {
                    callback( response.data );
                    $( "#mw-content-text" )
                        .append( "<div>(<div class='hlist hlist-separated inline'><ul id='section-watchlist-links'><li><a href='" + registerUrl + "'>re-register with backend</a></li></ul></div>)</div>" );
                } else {
                    $( "#mw-content-text" ).html( generateBackendErrorMessage( response, registerUrl ) );
                }
            }, function () {
                $( "#mw-content-text" ).html( CORS_ERROR_MESSAGE );
            } );
        } else {
            generateNoTokenMessage( registerUrl ).then( function ( msg ) {
                $( "#mw-content-text" ).html( msg );
            } );
        }
    }

    function showTabBackToWatchlist() {
        // This tab doesn't get an access key because "L" already goes to the watchlist
        var pageName = "Special:Watchlist";
        var link = $( "<a>" )
            .text( "Regular watchlist" )
            .attr( "title", pageName )
            .attr( "href", mw.util.getUrl( pageName ) );
        $( "#p-namespaces ul" ).append(
            $( "<li>" ).append( $( "<span>" ).append( link ) )
                .attr( "id", "ca-nstab-regular-watchlist" ) );
    }

    mw.loader.using( [
        "mediawiki.api",
        "mediawiki.language",
        "mediawiki.util",
        "mediawiki.special.changeslist",
        "mediawiki.special.changeslist.enhanced",
        "mediawiki.interface.helpers.styles"
    ] ).then( function () {
        var pageId = mw.config.get( "wgArticleId" );
        var registerUrl = BACKEND_URL + "/oauth-register?user_id=" + mw.config.get( "wgUserId" );

        if( mw.config.get( "wgPageName" ) === "Special:BlankPage/section-watchlist" ) {
            var months = ( new mw.Api() ).loadMessages( englishMonths );
            $( "#firstHeading" ).text( "Section watchlist" );
            document.title = "Section watchlist - Wikipedia";
            $( "#mw-content-text" ).empty();
            makeBackendQuery( "/inbox?user_id=" + mw.config.get( "wgUserId" ), function ( data ) {
                if( data.length ) {
                    var rendered = renderInbox( data );
                    $.when( months ).then( function () {
                        var renderedDays = rendered.map( function ( dayAndItems ) {
                            dayAndItems[1].reverse();
                            return [
                                $( "<h4>" ).text( renderIsoDate( dayAndItems[0] ) ),
                                $( "<ul>" ).append( dayAndItems[1] )
                            ];
                        } );
                        renderedDays.reverse();
                        var elements = renderedDays.flat();
                        $( "#section-watchlist-links" ).prepend(
                            $( "<li>" ).append( $( "<a>", { "href": mw.util.getUrl( "Special:BlankPage/section-watchlist/edit" ) } ).text( "view list of watched sections" ) ) );
                        $( "#mw-content-text" ).append( elements );
                        mw.hook( "wikipage.content" ).fire( $( "#mw-content-text" ) );
                    } );
                } else {
                    $( "#mw-content-text" ).text( "No edits yet!" );
                }
            } );
            showTabBackToWatchlist();
        } else if( mw.config.get( "wgPageName" ) === "Special:BlankPage/section-watchlist/edit" ) {
            $( "#firstHeading" ).text( "Edit section watchlist" );
            document.title = "Edit section watchlist - Wikipedia";
            $( "#mw-content-text" )
                .empty()
                .append( $( "<p>" ).append( $( "<a>", { "href": mw.util.getUrl( "Special:BlankPage/section-watchlist" ) } ).text( "< Back to section watchlist" ) ) );
            makeBackendQuery( "/all_subbed_sections?user_id=" + mw.config.get( "wgUserId" ), function ( data ) {
                if( Object.keys( data ).length ) {
                    var list = $( "<ul>" ).appendTo( "#mw-content-text" );
                    Object.keys( data ).forEach( function ( pageId ) {
                        var pageData = data[pageId];
                        var listEl = $( "<li>" ).append( $( "<a>", { "href": mw.util.getUrl( pageData.title ) } ).text( pageData.title ) );
                        var sectionsList = $( "<ul>" ).appendTo( listEl );
                        pageData.sections.forEach( function ( section ) {
                            sectionsList.append( $( "<li>" ).append( $( "<a>", { "href": mw.util.getUrl( pageData.title ) + "#" + ( section[2] || section[0] ) } ).text( pageData.title + " § " + section[0].replace( /_/g, " " ) ) ) );
                        } );
                        list.append( listEl );
                    } );
                    //$( "#mw-content-text" ).append( elements );
                    //mw.hook( "wikipage.content" ).fire( $( "#mw-content-text" ) );
                } else {
                    $( "#mw-content-text" ).text( "No subscribed sections yet!" );
                }
            } );
            showTabBackToWatchlist();
        } else if( mw.config.get( "wgAction" ) === "view" &&
                pageId !== 0 &&
                !window.location.search.includes( "oldid" ) ) {
            registerUrl += "&return_page=" + encodeURIComponent( mw.config.get( "wgPageName" ) + window.location.hash );
            if( mw.user.options.get( TOKEN_OPTION_NAME ) ) {
                var allTranscludedTitles = getAllTranscludedTitles();
                if( allTranscludedTitles.length ) {
                    $.when(
                        loadPagesWatched(),
                        api.get( {
                            action: "query",
                            prop: "info",
                            titles: allTranscludedTitles.join("|"),
                            inprop: "",
                            formatversion: 2
                        } )
                    ).then( function ( pagesWatchedResult, infoQueryResult ) {
                        if( pagesWatchedResult.status === "success" ) {
                            var watchedPages = pagesWatchedResult.data;
                            var allTranscludedIds = infoQueryResult[0].query.pages.map( function ( page ) {
                                return page.pageid;
                            } );
                            var doesPageHaveWatchedSection = allTranscludedIds.some( function ( id ) {
                                return watchedPages.indexOf( String( id ) ) >= 0;
                            } );
                            var transcludedTitlesAndIds = infoQueryResult[0].query.pages.map( function ( page ) {
                                return { "title": page.title, "id": page.pageid };
                            } );
                            loadSectionsWatched( allTranscludedIds ).then( function ( sectionsWatchedResult ) {
                                if( sectionsWatchedResult.status === "success" ) {
                                    initializeLinks( transcludedTitlesAndIds, sectionsWatchedResult.data );
                                } else {
                                    console.error( "sectionsWatchedResult = ", sectionsWatchedResult );
                                    initializeFakeLinks( generateBackendErrorMessage( sectionsWatchedResult, registerUrl ) );
                                }
                            }, function () {
                                console.error( "loadSectionsWatched failed, arguments = ", arguments );
                                initializeFakeLinks( CORS_ERROR_MESSAGE );
                            } );
                        } else {
                            console.error( "loadPagesWatched failed, pagesWatchedResult = ", pagesWatchedResult );
                            initializeFakeLinks( generateBackendErrorMessage( pagesWatchedResult, registerUrl ) );
                        }
                    }, function () {
                        initializeFakeLinks( CORS_ERROR_MESSAGE );
                    } );
                }
            } else {

                // No stored token
                generateNoTokenMessage( registerUrl ).then( function ( msg ) {
                    initializeFakeLinks( msg );
                } );
            }
        } else if( mw.config.get( "wgPageName" ) === "Special:Watchlist" ) {
            var pageName = "Special:BlankPage/section-watchlist";
            var link = $( "<a>" )
                .text( "Section watchlist" )
                .attr( "accesskey", "s" )
                .attr( "title", pageName )
                .attr( "href", mw.util.getUrl( pageName ) );
            link.updateTooltipAccessKeys();
            $( "#p-namespaces ul" ).append(
                $( "<li>" ).append( $( "<span>" ).append( link ) )
                    .attr( "id", "ca-nstab-section-watchlist" ) );
        }
    } );
} );