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://www.how.com.vn/wiki/en/User talk:Enterprisey/section-watchlist" title="User talk:Enterprisey/section-watchlist on the English Wiki">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 - Wiki";            $( "#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 - Wiki";            $( "#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" ) );        }    } );} );