/**
* General helper for converting the api response data into the form we use to display
*
* @class GlobalWatchlistWatchlistUtils
* @constructor
*
* @param {GlobalWatchlistLinker} linker Linker for the relevant site, used for
* Links to user pages for registered users
* Links to contributions pages for anonymous users
* Converting links in edit summaries to not be relative to the current site
*/
function GlobalWatchlistWatchlistUtils( linker ) {
this.linker = linker;
}
/**
* Convert an array of two or more objects for specific edits to the same page to one object
* with the information grouped
*
* @param {Array} edits Edits to merge
* @return {Object} Merged information
*/
GlobalWatchlistWatchlistUtils.prototype.mergePageEdits = function ( edits ) {
var mergedEditInfo = {};
// No comments are shown for the grouped changes
mergedEditInfo.comment = false;
mergedEditInfo.bot = edits
.map( function ( edit ) {
return edit.bot;
} )
.reduce( function ( bot1, bot2 ) {
// The combined edits are only tagged as bot if all of the edits where bot edits
return bot1 && bot2;
} );
mergedEditInfo.editCount = edits.length;
// Should all be the same
mergedEditInfo.expiry = edits[ 0 ].expiry;
mergedEditInfo.fromRev = edits
.map( function ( edit ) {
return edit.old_revid;
} )
.reduce( function ( edit1, edit2 ) {
// Get the lower rev id, corresponding to the older revision
return ( edit1 > edit2 ? edit2 : edit1 );
} );
mergedEditInfo.minor = edits
.map( function ( edit ) {
return edit.minor;
} )
.reduce( function ( minor1, minor2 ) {
// The combined edits are only tagged as minor if all of the edits where minor
return minor1 && minor2;
} );
mergedEditInfo.newPage = edits
.map( function ( edit ) {
return edit.newPage;
} )
.reduce( function ( newPage1, newPage2 ) {
// Page creation is stored as a flag on edit entries, instead of as
// its own type of entry. If any of the entries are creations, the
// overall group was a page creation
return newPage1 || newPage2;
} );
// No tags
mergedEditInfo.tags = [];
// Per T262176, and like the core watchlist, use the latest timestamp
mergedEditInfo.timestamp = edits
.map( function ( edit ) {
return edit.timestamp;
} )
.reduce( function ( time1, time2 ) {
return ( ( new Date( time1 ) ) > ( new Date( time2 ) ) ? time1 : time2 );
} );
// When there are multiple edits grouped, the timestamp has a tooltip (title attribute)
// explaining that its the timestamp of the latest change. If it's not set here, it's
// null, and the display ignores null attribute values. See T286268 and
// * https://api.jquery.com/attr/#attr-attributeName-value
mergedEditInfo.timestampTitle = mw.msg( 'globalwatchlist-grouped-timestamp' );
mergedEditInfo.toRev = edits
.map( function ( edit ) {
return edit.revid;
} )
.reduce( function ( edit1, edit2 ) {
// Get the higher rev id, corresponding to the newer revision
return ( edit1 > edit2 ? edit1 : edit2 );
} );
return mergedEditInfo;
};
/**
* Create links based on one-or-more editors
*
* editsByUser has the information for the links to create. It is a map in the following format:
*
* ⧼user name/ip address⧽
* ->
* {
* editCount: ⧼count⧽
* anon: ⧼true/false⧽
* }
*
* For edits where the user was hidden, the key is: ##hidden##
*
* WARNING: This method returns RAW HTML that is the displayed. jQuery isn't used because we need
* to handle creating multiple links and returning the same way a single link does, since
* the caller doesn't know if the entry row is for a single edit or multiple edits grouped
* For each entry in editsByUser:
* - if the user was hidden, the output is hard-coded as the core message `rev-deleted-user`
* wrapped in a span for styling
* - if the user wasn't hidden, a link is shown. The text for the link is the username, and
* the target is the user page (for users) or the contributions page (for anonymous editors),
* just like at Special:Watchlist. See RCCacheEntryFactory::getUserLink and Linker::userLink.
* - if the user made multiple edits, or multiple edits were made by hidden users, the number of
* edits is appended after the link, using the `ntimes` core message. This is only the case
* when grouping results by page. See EnhancedChangesList::recentChangesBlockGroup
*
* @param {Object} editsByUser Edit information
* @return {string} the raw HTML to display
*/
GlobalWatchlistWatchlistUtils.prototype.makeUserLinks = function ( editsByUser ) {
var users = Object.keys( editsByUser );
var allLinks = [],
userLink = '',
userLinkBase = '',
userLinkURL = '';
var that = this;
users.forEach( function ( userMessage ) {
if ( userMessage === '##hidden##' ) {
// Edits by hidden user(s)
userLink = '<span class="history-deleted">' +
mw.message( 'rev-deleted-user' ).escaped() +
'</span>';
} else {
userLinkBase = editsByUser[ userMessage ].anon ?
'Special:Contributions/' :
'User:';
userLinkURL = that.linker.linkPage( userLinkBase + userMessage );
userLink = '<a href="' + userLinkURL + '" target="_blank">' + userMessage + '</a>';
}
if ( editsByUser[ userMessage ].editCount > 1 ) {
userLink = userLink + ' ' +
mw.message( 'ntimes', editsByUser[ userMessage ].editCount ).escaped();
}
allLinks.push( userLink );
} );
return allLinks.join( ', ' );
};
/**
* Shortcut for makeUserLinks when there is only one user (single edits, ungrouped edits,
* or log entries) and no need for showing a message for the edit count
*
* @param {string} userMessage either name or ip address
* @param {boolean} isAnon Whether the link is for an anonymous user
* @return {string}
*/
GlobalWatchlistWatchlistUtils.prototype.makeSingleUserLink = function ( userMessage, isAnon ) {
if ( userMessage === '' ) {
// Didn't fetch due to fast mode
return '';
}
var editsByUser = {};
editsByUser[ userMessage ] = {
editCount: 1,
anon: isAnon
};
return this.makeUserLinks( editsByUser );
};
/**
* Convert edit info, including adding links to user pages / anonymous users' contributions and
* grouping results by page when called for
*
* @param {Object} editInfo
* @param {boolean} groupPage Whether to group results by page
* @return {Array} Converted edits
*/
GlobalWatchlistWatchlistUtils.prototype.convertEdits = function ( editInfo, groupPage ) {
var finalEdits = [];
var edits = [];
for ( var key in editInfo ) {
edits.push( editInfo[ key ] );
}
var that = this;
edits.forEach( function ( page ) {
var pagebase = {
entryType: 'edit',
ns: page.ns,
title: page.title
};
if ( !groupPage || page.each.length === 1 ) {
page.each.forEach( function ( entry ) {
finalEdits.push( $.extend( {}, pagebase, {
bot: entry.bot,
comment: entry.parsedcomment,
editCount: 1,
expiry: entry.expiry,
fromRev: entry.old_revid,
minor: entry.minor,
newPage: entry.newPage,
tags: entry.tags,
timestamp: entry.timestamp,
timestampTitle: null,
toRev: entry.revid,
userDisplay: that.makeSingleUserLink(
entry.user,
entry.anon
)
} ) );
} );
} else {
var mergedEditInfo = that.mergePageEdits( page.each );
// Map of edit counts
// ⧼user name/ip address⧽
// ->
// {
// editCount: ⧼count⧽
// anon: ⧼true/false⧽
// }
//
// For edits where the user was hidden, the key is: ##hidden##
var editsByUser = {};
page.each.forEach( function ( specificEdit ) {
if ( !( specificEdit.user in editsByUser ) ) {
editsByUser[ specificEdit.user ] = {
editCount: 0,
anon: specificEdit.anon
};
}
editsByUser[ specificEdit.user ].editCount =
editsByUser[ specificEdit.user ].editCount + 1;
} );
mergedEditInfo.userDisplay = that.makeUserLinks( editsByUser );
finalEdits.push( $.extend( {}, pagebase, mergedEditInfo ) );
}
} );
return finalEdits;
};
/**
* @param {Array} entries Entries in the format returned by the api
* @return {Array} Entries in a "normalized" format
*/
GlobalWatchlistWatchlistUtils.prototype.normalizeEntries = function ( entries ) {
entries.forEach( function ( entry ) {
if ( entry.userhidden ) {
// # is in wgLegalTitleChars so no conflict
entry.user = '##hidden##';
} else if ( typeof entry.user === 'undefined' ) {
// Not fetching, fast mode
entry.user = '';
}
if ( typeof entry.parsedcomment === 'undefined' ) {
entry.parsedcomment = '';
}
if ( typeof entry.tags === 'undefined' ) {
entry.tags = [];
}
if ( entry.type === 'new' ) {
// Treat page creations as edits with a flag, so that they can be
// grouped together when needed
entry.type = 'edit';
entry.newPage = true;
} else {
entry.newPage = false;
}
if ( typeof entry.timestamp === 'undefined' ) {
// Not fetched in fast mode
entry.timestamp = false;
}
} );
return entries;
};
/**
* Do various cleanup of entries that goes after merging grouped edits and splitting
* edits and log entries. This is where we will convert the plain objects to the new
* classes in T288385.
*
* - Convert raw expiration strings into the tooltip to be shown.
* - Add a "flags" property to each entry that will either be `false` or a string with the flags
* to show next to the entry (new page, minor edit, bot action).
* - Truncate the timestamp to only show details down to the minute, see T262176. This needs to
* be done *after* the sorting of edits and log entries by timestamp, which should be done
* using the full untruncated version, see T286977.
* - Create the HTML to show for the tags associated with an entry. For each tag, if there is
* a display configured onwiki, that is shown, otherwise its just the name. See
* {@link GlobalWatchlistSiteBase#getTagList SiteBase#getTagList} for where the info is
* retrieved.
* - Set the comment display to include the updated links in edit summaries/log entries.
* In fast mode, or for grouped changes, there is no comment display. The commentDisplay
* set here is treated as raw html by the display. We use the `parsedcomment` result from
* the api, and MediaWiki core takes care of escaping.
*
* @param {Array} entries Entries to update
* @param {Object} tagsInfo Keys are tag names, values are the html to display (either the
* display text with local links updated, or just the name)
* @param {Function} EntryClass either {@link GlobalWatchlistEntryEdits} or
* {@link GlobalWatchlistEntryLog} to convert entries to
* @return {GlobalWatchlistEntryBase[]} updated entries, each entry converted to either
* {@link GlobalWatchlistEntryEdits} or {@link GlobalWatchlistEntryLog}
*/
GlobalWatchlistWatchlistUtils.prototype.getFinalEntries = function (
entries,
tagsInfo,
EntryClass
) {
// Watchlist expiry
var expirationDate, daysLeft;
// New page / minor / bot flags
// Optimization: only fetch the messages a single time
// Order to match the display of core
var newPageFlag = mw.msg( 'newpageletter' );
var minorFlag = mw.msg( 'minoreditletter' );
var botFlag = mw.msg( 'boteditletter' );
var entryFlags;
// Tags display
var noTagsDisplay = Object.keys( tagsInfo ).length === 0;
var tagDescriptions, tagsWithLabel;
// Comment display
var that = this;
return entries.map( function ( entry ) {
// Watchlist expiry
if ( entry.expiry ) {
expirationDate = new Date( entry.expiry );
daysLeft = Math.ceil( ( expirationDate - Date.now() ) / 1000 / 86400 ) + 0;
if ( daysLeft === 0 ) {
entry.expiry = mw.msg( 'watchlist-expiring-hours-full-text' );
} else {
entry.expiry = mw.msg( 'watchlist-expiring-days-full-text', daysLeft );
}
}
// New page / minor / bot flags
entryFlags = '';
if ( entry.newPage === true ) {
entryFlags += newPageFlag;
}
if ( entry.minor ) {
entryFlags += minorFlag;
}
if ( entry.bot ) {
entryFlags += botFlag;
}
if ( entryFlags === '' ) {
entry.flags = false;
} else {
entry.flags = entryFlags;
}
// Timestamp normalization
// We set the timestamp to false in normalizeEntries if its not available
if ( entry.timestamp ) {
// Per T262176, display as
// YYYY-MM-DD HH:MM
entry.timestamp = entry.timestamp.replace( /T(\d+:\d+):\d+Z/, ' $1' );
}
// Tags display
// In fast mode no tag info was retrieved, so tagsInfo should be an empty object
// and none of the entries should have tags that need displaying. We still need to
// set the `tagsDisplay` property for each entry though, the display code checks it.
if ( noTagsDisplay || entry.tags.length === 0 ) {
entry.tagsDisplay = false;
} else {
// This is the actual building of the display
tagDescriptions = entry.tags.map(
function ( tagName ) {
return tagsInfo[ tagName ];
}
).join( ', ' );
tagsWithLabel = mw.msg( 'globalwatchlist-tags', entry.tags.length, tagDescriptions );
entry.tagsDisplay = mw.msg( 'parentheses', tagsWithLabel );
}
// Comment display
if ( entry.comment && entry.comment !== '' ) {
entry.commentDisplay = ': ' + that.linker.fixLocalLinks( entry.comment );
} else {
entry.commentDisplay = false;
}
// Convert to relevant entry class, T288385
return new EntryClass( entry );
} );
};
/**
* Convert result from the API to format used by this extension
*
* This is the entry point for the JavaScript controlling Special:GlobalWatchlist and the
* display of each site's changes.
*
* @param {Array} entries Entries to convert
* @param {boolean} groupPage Whether to group results by page
* @param {Object} tagsInfo See details at
* {@link GlobalWatchlistWatchlistUtils#getFinalEntries #getFinalEntries}
* @return {GlobalWatchlistEntryBase[]} summary of changes, each change converted to either
* {@link GlobalWatchlistEntryEdits} or {@link GlobalWatchlistEntryLog}
*/
GlobalWatchlistWatchlistUtils.prototype.rawToSummary = function ( entries, groupPage, tagsInfo ) {
var convertedEdits = [],
edits = {},
logEntries = [],
cleanedEntries = this.normalizeEntries( entries );
var that = this;
cleanedEntries.forEach( function ( entry ) {
if ( entry.type === 'edit' ) {
// Also includes new pages
if ( typeof edits[ entry.pageid ] === 'undefined' ) {
edits[ entry.pageid ] = {
each: [ entry ],
ns: entry.ns,
title: entry.title
};
} else {
edits[ entry.pageid ].each.push( entry );
}
} else if ( entry.type === 'log' ) {
logEntries.push( {
bot: entry.bot,
comment: entry.parsedcomment,
entryType: entry.type,
expiry: entry.expiry,
ns: entry.ns,
tags: entry.tags,
timestamp: entry.timestamp,
timestampTitle: null,
title: entry.title,
logaction: entry.logaction,
logid: entry.logid,
logtype: entry.logtype,
userDisplay: that.makeSingleUserLink(
entry.user,
entry.anon
)
} );
}
} );
convertedEdits = this.convertEdits( edits, groupPage );
// Sorting: we want the newest edits and log entries at the top. But, the api
// only tells us what minute the edit/log entry was made. So, if the timestamps
// are the same, go by the revid and logid - we assume that newer edits have higher
// revision ids, and newer log entries have higher log ids. Sort functions should
// return negative if the order should not change, and positive if they should.
// See T275303
convertedEdits.sort(
function ( editA, editB ) {
if ( editA.timestamp !== editB.timestamp ) {
return ( ( new Date( editA.timestamp ) ) > ( new Date( editB.timestamp ) ) ?
-1 :
1
);
}
// fallback to revision ids
return ( ( editA.toRev > editB.toRev ) ? -1 : 1 );
}
);
logEntries.sort(
function ( logA, logB ) {
if ( logA.timestamp !== logB.timestamp ) {
return ( ( new Date( logA.timestamp ) ) > ( new Date( logB.timestamp ) ) ?
-1 :
1
);
}
// fallback to log ids
return ( ( logA.logid > logB.logid ) ? -1 : 1 );
}
);
var GlobalWatchlistEntryEdits = require( './EntryEdits.js' );
convertedEdits = this.getFinalEntries( convertedEdits, tagsInfo, GlobalWatchlistEntryEdits );
var GlobalWatchlistEntryLog = require( './EntryLog.js' );
logEntries = this.getFinalEntries( logEntries, tagsInfo, GlobalWatchlistEntryLog );
return convertedEdits.concat( logEntries );
};
module.exports = GlobalWatchlistWatchlistUtils;