User:Enterprisey/section-watchlist.js

// 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 = 'User talk:Enterprisey/section-watchlist';

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(

" | ",

$( "" ).append(

$( "" )

.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: $( '

', { 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(

" | ",

$( "" )

.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 = /\n  <\/td>\n (?:

([^<]*)<\/div>)?<\/td>\n  <\/td>\n .*?<\/td>\n<\/tr>\n/g;

var ADDED_ROW = /\n  <\/td>\n \+<\/td>\n (?:

([^<]*)<\/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>||<\/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 $( "", { "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 ),

$( "", { "class": "mw-changeslist-line-inner-articleLink" } ).append(

$( "", { "class": "mw-title" } ).append(

$( "", { "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]

// ? $( "" ).append( "(under ", $( "", { "href": secondaryUrl } ).text( streamEvent.target[2][0] ) )

// : "",

streamEvent.data.revision["new"]

? $( "", { "class": "mw-changeslist-line-inner-historyLink" } ).append(

$( "", { "class": "mw-changeslist-links" } ).append(

$( "" ).append(

// The URL parameters must be in this order, or Navigation Popups will not work for this link. (UGH.)

$( "", {

"class": "mw-changeslist-diff",

"href": mw.util.getUrl( "", {

"title": streamEvent.title,

"diff": "prev",

"oldid": streamEvent.data.revision["new"]

} )

} ).text( "diff" ) ),

$( "" ).append(

$( "", { "class": "mw-changeslist-history", "href": mw.util.getUrl( streamEvent.title, { "action": "history" } ) } )

.text( "hist" ) ),

) )

: "",

$( "", { "class": "mw-changeslist-line-inner-separatorAfterLinks" } ).append(

$( "", { "class": "mw-changeslist-separator" } ) ),

renderLengthDiff( streamEvent.data.length.old, streamEvent.data.length["new"] ),

$( "", { "class": "mw-changeslist-line-inner-separatorAftercharacterDiff" } ).append(

$( "", { "class": "mw-changeslist-separator" } ) ),

$( "", { "class": "mw-changeslist-line-inner-userLink" } ).append(

$( "", { "class": "mw-userlink", "href": mw.util.getUrl( "User:" + streamEvent.user ), "title": "User:" + streamEvent.user } ).append(

$( "" ).text( streamEvent.user ) ) ),

$( "", { "class": "mw-changeslist-line-inner-userTalkLink" } ).append(

$( "", { "class": "mw-usertoollinks mw-changeslist-links" } ).append(

$( "" ).append(

$( "", { "class": "mw-usertoollinks-talk", "href": mw.util.getUrl( "User talk:" + streamEvent.user ), "title": "User talk:" + streamEvent.user } )

.text( "talk" ) ),

$( "" ).append(

$( "", { "class": "mw-usertoollinks-contribs", "href": mw.util.getUrl( "Special:Contributions/" + streamEvent.user ), "title": "Special:Contributions/" + streamEvent.user } )

.text( "contribs" ) ) ) ),

streamEvent.data.minor

? $( "", { "class": "minoredit", "title": "This is a minor edit" } ).text( "m" )

: "",

$( "", { "class": "mw-changeslist-line-inner-comment" } ).append(

$( "", { "class": "comment comment--without-parentheses" } ).append(

$( "", { "dir": "auto" } ).append( streamEvent.parsedcomment ) ) )

];

if( streamEvent.data.is_simple_addition ) {

els.push( $( "" ).append( "(", $( "", { "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 = $( "

  • " ).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( $( "

  • " ).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 the registration page.';

    }, 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 the registration page.";

    case "bad_token":

    return "Authentication failed. Please re-authenticate by visiting the registration page.";

    }

    }

    return "Request failed (error: " + backendResponse.status + "/" + backendResponse.data +

    "). Re-authenticating by visiting the registration page 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( "

    " );

    } 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 = $( "" )

    .text( "Regular watchlist" )

    .attr( "title", pageName )

    .attr( "href", mw.util.getUrl( pageName ) );

    $( "#p-namespaces ul" ).append(

    $( "

  • " ).append( $( "" ).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 [

    $( "

    " ).text( renderIsoDate( dayAndItems[0] ) ),

    $( "