User:Yair rand/HistoryView.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
This user script seems to have a documentation page at User:Yair rand/HistoryView. |
/**
* Display history in a more readable manner.
*
* To enable, add importScript( 'User:Yair_rand/HistoryView.js' ); to your [[Special:MyPage/common.js]]
*
*
* @author Yair Rand ([[User:Yair rand]])
* @version 0.1.4
*/
// Important todos:
// * Finish selectRows.
// ** Temporarily just set to exit row select when panning or zooming.
// Things I am very unsure about:
// * Display of reverts. The gradient thing might be unclear and/or ugly.
// * Whether there should be gaps for unedited lines in the display.
// *
// TODO: Move logs. (Depends on T10731.)
// TODO: Bot flag icons. (Depends on T13181.)
// Tags are absent, but not available as DOM via API. (No phab task open afaict.) TODO: Figure something out.
// TODO: This is broken for certain non-English languages, as numbers in 'Line XX' aren't arabic numerals.
// PROBLEM: If zooming to one col, then pan to protect, there are no columns.
// Logs' edits are removed, but still stored in revisions in . Necessary, bc
// otherwise would be inconsistent with edit count.
// Maybe modify pan to skip in those situations?
// TODO: Ask someone if old protect logs are incomplete. (MW Main Page, Brion's 2007 protect has no params or details.)
// Idea: Maybe have locked "Line ##" above the diffHolder, updating on scroll?
// Idea: An "expand" icon between groups of context lines, to fill in from other
// changes.
// Idea: An option to show ORES score?
mw.config.get( 'wgAction' ) === 'history' && mw.config.get( 'wgPageContentModel' ) === 'wikitext' && Promise.all( [
Promise.resolve( $.ready ),
mw.loader.using( [
'mediawiki.api',
'mediawiki.Title',
'oojs-ui-core',
'oojs-ui-widgets'
] )
] ).then( function () {
var
// Height of the canvas element.
fullHeight = 300,
// Height of the vertical bars dividing changes from each other.
barsHeight = 250,
// The area that includes the changes themselves
changeAreaHeight = 170,
// ...and at the bottom of the bars area, the usernames. (There's a 5px gap
// between the changes and usernames: 250 - 170 - 75 = 5. )
userNameHeight = 75,
// Height of the "diffHolder" element which holds the visible diff tables.
spaceHeight = 300,
// Width of the content area.
fullWidth,
changeRows = [],
changeCols = [],
logIcons = [],
settings = ( ( settingsString ) => {
return settingsString ? JSON.parse( settingsString ) : {};
} )( mw.user.options.get( 'userjs-historyview-settings' ) ),
canvas = document.createElement( 'canvas' ),
canvasDisplay,
domHandler,
onWatchlist = !!document.querySelector( '#ca-unwatch' ),
i18n = {
en: {
'HV-Loading': 'Loading...',
'HV-Position': '$1 - $2 of $3',
'HV-ShowEarliest': 'Show earliest changes',
'HV-ShowEarlier': 'Show earlier changes',
'HV-ShowLater': 'Show more recent changes',
'HV-ShowLatest': 'Show most recent changes',
'HV-ZoomIn': 'Zoom in',
'HV-ZoomOut': 'Zoom out',
'HV-Disable': 'Disable HistoryView.js',
'HV-Enable': 'Enable HistoryView.js',
'HV-DisableTT': 'Return to basic history view',
'HV-EnableTT': '', // TODO
'HV-ViewLogs': 'View logs',
'HV-SelectDate': 'Select date',
'HV-FilterTags': 'Filter by tags',
'HV-LastVisited': 'You last visited this page before $1.',
'HV-MultiRev': 'Showing multiple revisions',
'HV-ProtectLog': '$1 protected the page.',
'HV-UnprotectLog': '$1 unprotected the page.',
'HV-DeleteLog': '$1 deleted the page.',
'HV-RestoreLog': '$1 restored the page.',
'HV-MoveLog': '$1 moved the page.',
'HV-DeletedRev': '(deleted)',
'HV-DeletedUser': '(removed)',
'HV-RemovedUser': '(Username or IP removed)'
}
},
icons = {
// Icons 'lock', 'unlock', 'trash', 'undo', 'exchange-alt', 'times', 'eye' from FontAwesome.
// * Font Awesome Free 5.1.0 by @fontawesome - https://fontawesome.com
// * License - https://fontawesome.com/license (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
'protect': [ // 'lock'
448, 512,
`M400 224h-24v-72C376 68.2 307.8 0 224 0S72 68.2 72 152v72H48c-26.5 0-48
21.5-48 48v192c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5
48-48V272c0-26.5-21.5-48-48-48zm-104 0H152v-72c0-39.7 32.3-72 72-72s72
32.3 72 72v72z`
],
'unprotect': [ // 'unlock'
448, 512,
`M400 256H152V152.9c0-39.6 31.7-72.5 71.3-72.9 40-.4 72.7 32.1 72.7
72v16c0 13.3 10.7 24 24 24h32c13.3 0 24-10.7 24-24v-16C376 68 307.5-.3
223.5 0 139.5.3 72 69.5 72 153.5V256H48c-26.5 0-48 21.5-48 48v160c0 26.5
21.5 48 48 48h352c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48z`
],
'delete': [ // 'trash'
448, 512,
`M0 84V56c0-13.3 10.7-24 24-24h112l9.4-18.7c4-8.2 12.3-13.3
21.4-13.3h114.3c9.1 0 17.4 5.1 21.5 13.3L312 32h112c13.3 0 24 10.7 24
24v28c0 6.6-5.4 12-12 12H12C5.4 96 0 90.6 0 84zm415.2 56.7L394.8
467c-1.6 25.3-22.6 45-47.9 45H101.1c-25.3 0-46.3-19.7-47.9-45L32.8
140.7c-.4-6.9 5.1-12.7 12-12.7h358.5c6.8 0 12.3 5.8 11.9 12.7z`
],
// Currently using simple "undo" icon.
'restore': [
512, 512,
`M212.333 224.333H12c-6.627 0-12-5.373-12-12V12C0 5.373 5.373 0 12
0h48c6.627 0 12 5.373 12 12v78.112C117.773 39.279 184.26 7.47 258.175
8.007c136.906.994 246.448 111.623 246.157 248.532C504.041 393.258 393.12
504 256.333 504c-64.089
0-122.496-24.313-166.51-64.215-5.099-4.622-5.334-12.554-.467-17.42l33.967-33.967c4.474-4.474 11.662-4.717
16.401-.525C170.76 415.336 211.58 432 256.333 432c97.268 0 176-78.716
176-176 0-97.267-78.716-176-176-176-58.496 0-110.28 28.476-142.274
72.333h98.274c6.627 0 12 5.373 12 12v48c0 6.627-5.373 12-12 12z`
],
'move': [ // 'exchange-alt'
512, 512,
`M0 168v-16c0-13.255 10.745-24 24-24h360V80c0-21.367 25.899-32.042
40.971-16.971l80 80c9.372 9.373 9.372 24.569 0 33.941l-80 80C409.956
271.982 384 261.456 384 240v-48H24c-13.255 0-24-10.745-24-24zm488
152H128v-48c0-21.314-25.862-32.08-40.971-16.971l-80 80c-9.372
9.373-9.372 24.569 0 33.941l80 80C102.057 463.997 128 453.437 128
432v-48h360c13.255 0 24-10.745 24-24v-16c0-13.255-10.745-24-24-24z`
],
'revdeleted': [ // 'times'
352, 512,
`M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19
0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93
89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28
32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0
44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07
100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28
12.28-32.19 0-44.48L242.72 256z`
],
'lastSeen': [ // 'eye'
576, 512,
`M569.354 231.631C512.969 135.949 407.81 72 288 72 168.14 72 63.004
135.994 6.646 231.631a47.999 47.999 0 0 0 0 48.739C63.031 376.051 168.19
440 288 440c119.86 0 224.996-63.994 281.354-159.631a47.997 47.997 0 0 0
0-48.738zM288 392c-75.162 0-136-60.827-136-136 0-75.162 60.826-136
136-136 75.162 0 136 60.826 136 136 0 75.162-60.826 136-136
136zm104-136c0 57.438-46.562 104-104 104s-104-46.562-104-104c0-17.708
4.431-34.379 12.236-48.973l-.001.032c0 23.651 19.173 42.823 42.824
42.823s42.824-19.173
42.824-42.823c0-23.651-19.173-42.824-42.824-42.824l-.032.001C253.621
156.431 270.292 152 288 152c57.438 0 104 46.562 104 104z`
]
// TODO: 'Merge' icon.
// For users, maybe: block (hand?), unblock, merge, userrights, usercreate...
};
/**
* Format a date to "00:00 1 January 2018" style.
* @param {Date} timestamp
*/
function formatTimestamp( timestamp ) {
return timestamp.getUTCHours().toString().padStart( 2, 0 ) + ':' +
timestamp.getUTCMinutes().toString().padStart( 2, 0 ) + ', ' +
mw.language.months.names[ timestamp.getUTCMonth() ] + ' ' +
timestamp.getUTCDate() + ' ' +
timestamp.getUTCFullYear();
}
/**
* For managing API requests and such.
*/
var apiHandler = ( () => {
// This uses no external vars other than mw and onWatchlist.
/**
* Set up a cache of linear API results of a particular type.
*
* @param {string} type
* @param {('revid'/'logid')} idType
* @param {'rvcontinue'/'lecontinue'} continueTokenType
* @return {Object}
*/
function resultsCache( type, idType, continueTokenType ) {
/**
*
*/
function setToEdge( dir ) {
cache.active = lists[ dir === 1 ? 'start' : 'end' ];
}
/**
* Check if the entries in cache.start and cache.end have any overlap, and
* if they do, extend both to include all data from each other.
*/
function attemptLinkUp() {
// Check two arrays for overlap, to link up. ALso set completion status if
// linked up from end to end. TODO: Also, check links for mid-range arrays, like from date ranges.
// NOTE: Don't lose cached elements in merging.
// Wait, is anything cached in the lists? Or only in the compares, which
// don't have this issue anyway?
// NOTE: Being linked implies being completed, for the mains.
// Linking can happen from completing either side, or by finding overlap.
var { start, end } = lists,
lastEntryInEnd = end.list.slice( -1 )[ 0 ],
// The lists are nicely ordered, so we only need to check edges of each
// for matches.
matchPoint = start.list.findIndex( entry => entry[ idType ] === lastEntryInEnd[ idType ] );
// TODO: Refactor. Less duplication between two sections.
// Mutate the existing arrays, don't assign new ones, so that references
// from .active don't break.
// If one side has the whole list, just use that for both.
if ( cache.completed ) {
// Use the completed one to fill in both sides.
// We assume that .active is the complete one, because that's what was
// just extended, and attemptLinkUp is only called (atm) during extending.
if ( cache.active === start ) {
end.list.push( ...start.list.slice( 0, -end.list.length || undefined ).reverse() );
} else {
start.list.push( ...end.list.slice( 0, -start.list.length || undefined ).reverse() );
}
} else if ( matchPoint !== -1 ) {
cache.completed = true;
end.list.push( ...start.list.slice( 0, matchPoint || undefined ).reverse() );
// start.list = end.list.slice( 0 ).reverse();
start.list.push( ...end.list.slice( 0, -start.list.length ).reverse() );
} else {
return false;
}
return true;
}
/**
* Add new results from the API to the cache.
*/
function addResults( apiResult, entries ) {
cache.active.list.push( ...entries );
cache.completed = !( 'continue' in apiResult );
cache.active.continueToken = apiResult.continue && apiResult.continue[ continueTokenType ];
attemptLinkUp();
}
var
// TODO: midLists also need continueTokens...
// Midlists needs many lists. Need to store anything other than the token and the list itself?
lists = {
// List changes from the start/earliest changes.
start: {
list: [],
continueToken: undefined
},
// List from the end/most recent changes.
end: {
list: [],
continueToken: undefined
}
},
cache = {
// Each set of stored results can simultaneously have different sets of
// results from different time periods, with no way of cross-indexing them.
// We might be running from the earliest, or latest, or some date in the middle.
// The "active" set is whichever we're currently navigating from.
active: lists.end,
completed: false,
type,
setToEdge,
addResults
};
return cache;
}
var busy = false,
// Cached API results.
stored = {
revisions: resultsCache( 'revision', 'revid', 'rvcontinue' ),
protectLogs: resultsCache( 'protect', 'logid', 'lecontinue' ),
deleteLogs: resultsCache( 'delete', 'logid', 'lecontinue' ),
lastSeen: null,
compares: {}
},
// Offset from the edge of whatever set of edits we're navigating.
offset = 0,
// Are we navigating from the most recent edits, or earliest?
fromRecent = true,
// How many edits to return at once?
rangeSize = 50,
// There are two issues where we need to record multiple rangeSize vars.
// * When filtering to rows.
// * (Also maybe when col filtering?)
preRowFilterRangeSize = false,
// Number of edits made to the page since creation.
editcount,
revDeleteLogs = {},
loadedInitData = false;
// Consider merging this with the logs things. Or at least less duplication.
/**
* Fetch revisions from the API and cache them, or get cached revisions.
*/
function getRevisions() {
var { revisions, revisions: { active: { list, continueToken } } } = stored;
// console.log( 'gr', offset, revisions );
if ( revisions.completed || offset + rangeSize <= list.length ) {
// Revisions are already cached.
let foundRevisions = list.slice( offset, offset + rangeSize );
if ( !fromRecent ) {
// Revisions offset from the start are stored in reverse order.
foundRevisions.reverse();
}
return Promise.resolve( foundRevisions );
} else {
// Fetch revisions.
busy = true;
return ( new mw.Api() ).get( {
action: 'query',
prop: 'revisions',
titles: mw.config.get( 'wgPageName' ),
// Should this just always grab 50?
rvlimit: offset + rangeSize - list.length,
rvprop: 'ids|user|flags|timestamp|sha1|tags',
rvdir: fromRecent ? 'older' : 'newer',
rvcontinue: continueToken
} ).then( result => {
revisions.addResults(
result,
Object.values( result.query.pages )[ 0 ].revisions
);
return getRevisions();
} );
}
}
function processCompare( compare, revision ) {
}
/**
*
*/
function getCompare( isEmptyDiff, fromrev, torev ) {
var fromrevid = fromrev.revid,
torevid = torev && torev.revid,
key = torev ? fromrevid + '-' + torevid : fromrevid;
if ( stored.compares[ key ] ) {
return Promise.resolve( stored.compares[ key ] );
} else {
var options = {
action: 'compare',
fromrev: fromrevid,
prop: 'diff|user|ids|comment|parsedcomment',
maxage: 60 * 60 * 24 * 30,
smaxage: 60 * 60 * 24 * 30
};
if ( torev ) {
options.torev = torevid;
} else {
options.torelative = 'prev';
}
return ( isEmptyDiff ? Promise.resolve( {} ) : ( new mw.Api() ).get( options ) )
.catch( ( error, ...y ) => {
console.log( 445, error, y );
// TODO: Show different things for actual errors than for deleted content.
// y has error messages.
return {
missingcontent: true,
error,
user: 'userhidden' in fromrev ? '?' : fromrev.user,
timestamp: new Date( fromrev.timestamp ),
compare: { '*': '(empty)' }
};
} )
.then( compare => {
// Extend compare result with result from revision.
compare.timestamp = new Date( fromrev.timestamp );
compare.sha1 = 'sha1hidden' in fromrev ? '?' : fromrev.sha1;
compare.user = 'userhidden' in fromrev ? '?' : fromrev.user;
compare.minor = 'minor' in fromrev;
compare.bot = 'bot' in fromrev;
compare.anon = 'anon' in fromrev;
compare.userhidden = 'userhidden' in fromrev;
compare.revid = fromrev.revid;
stored.compares[ key ] = compare;
return compare;
} );
}
}
/**
* Get diffs for a set of revisions.
*/
function getCompares( revisions ) {
// TODO: If rev.sha1hidden or sha1 matches prior, it might be possible to
// avoid fetching.
// Probably requires changing getRevisions to include edit summary.
return Promise.all( revisions.map( ( rev, i ) => {
var priorRev = revisions[ i + ( fromRecent ? 1 : -1 ) ],
isEmptyDiff = !!( priorRev && priorRev.sha1 === rev.sha1 && rev.sha1hidden === undefined );
// TODO: Also don't retrieve deleted edits, but do fill in the deleted edit data..
return getCompare( isEmptyDiff, rev );
} ) ).then( compares => compares.filter( x => x.compare && x.compare[ '*' ] ) );
}
/**
* Fetch logs via the API and store them, or get cached logs if available.
*/
function getLogs( cache ) {
// console.log( 'gl2', cache );
return ( new mw.Api() ).get( {
action: 'query',
list: 'logevents',
letitle: mw.config.get( 'wgPageName' ),
lelimit: 5,
letype: cache.type,
leprop: 'type|user|timestamp|comment|parsedcomment|details|ids',
ledir: fromRecent ? 'older' : 'newer',
lecontinue: cache.active.continueToken
} ).then( logResult => {
let newLogs = logResult.query.logevents;
cache.addResults( logResult, newLogs );
// Process revdelete logs.
newLogs.forEach( log => {
if ( log.action === 'revision' && log.type === 'delete' ) {
log.params.ids.forEach( revid => {
revDeleteLogs[ revid ] = revDeleteLogs[ revid ] || [];
// Avoid duplicates.
if ( revDeleteLogs[ revid ].every( dLog => dLog.logid !== log.logid ) ) {
revDeleteLogs[ revid ].push( log );
}
} );
}
} );
} );
}
// TODO: List log types.
// Protect, Unprotect, Delete (garbage can? ooui has "trash" and "unTrash"), undelete, merge.
// For users, maybe: block (hand? ooui has "block"/"unBlock"), unblock, merge, userrights, usercreate...
//
// In practise, for right now this should be just [un]protect and [un]delete.
/**
* Get log entries relevant to a specific time range.
*
* @param {Object} cache Set of logs (from stored).
* @param {String|undefined} start UTC date string, representing the earliest date allowed in the range
* @param {String|false} end
*/
function getLogsForRange( cache, start, end ) {
// console.log( 'gl', start, end, cache, cache.completed || cache.active.list.length && cache.active.list.slice( -1 )[ 0 ].timestamp );
// For other: Resolve when at least one before start.
var [ lastLog ] = cache.active.list.slice( -1 );
// Check if the cached logs already contain the range.
if ( cache.completed || lastLog && ( fromRecent ? lastLog.timestamp < start : lastLog.timestamp > end ) ) {
// TODO: Clean up comments here.
return Promise.resolve( cache.active.list.filter( ( log, i, allLogs ) =>
// Filter for timestamp.
(
!start || log.timestamp > start ||
(
// For protect logs, include log if expires before start, even if
// protection starts before start time.
log.type === 'protect' && log.action !== 'unprotect' &&
// Expires after start
( ( log.params && log.params.details && log.params.details[ 0 ].expiry !== 'infinite' ) ?
log.params.details[ 0 ].expiry > start :
// No explicit expiry given, or indefinite. Assume that it will
// only expire at the next change.
( !allLogs[ fromRecent ? i - 1 : i + 1 ] || allLogs[ fromRecent ? i - 1 : i + 1 ].timestamp > start )
)
)
) &&
( !end || log.timestamp < end )
) );
} else {
// We don't have enough log data available. Get some from the API, then
// try again.
return getLogs( cache ).then( () => {
return getLogsForRange( cache, start, end );
} );
}
}
/**
* If the page is on the user's watchlist, find out when the user's last
* visit to the page was.
*/
function getLastSeen() {
if ( onWatchlist ) {
if ( stored.lastSeen ) {
return stored.lastSeen;
} else {
return ( new mw.Api() ).get( {
prop: 'info',
inprop: 'notificationtimestamp',
titles: mw.config.get( 'wgPageName' )
} ).then( result => {
var lastSeen = Object.values( result.query.pages )[ 0 ].notificationtimestamp,
log = {
type: 'lastSeen',
timestamp: lastSeen
};
stored.lastSeen = lastSeen ? [ log ] : [];
return stored.lastSeen;
} );
}
} else {
return Promise.resolve( [] );
}
}
function getTimeStamp( continueToken ) {
return continueToken && continueToken.split( '|' )[ 0 ].replace(
/(....)(..)(..)(..)(..)(..)/,
'$1-$2-$3T$4:$5:$6Z'
);
}
/**
* Get edits and logs for the current range.
* @return {Promise}
*/
function getData() {
// Logs should start running at the same time as revisions, not afterwards.
var revisionsPromise = getRevisions();
return Promise.all( [
revisionsPromise
.then( revs => getCompares( revs ) ),
revisionsPromise
.then( revs => {
// This might be incomprehensible... TODO: Cleanup.
var { active, completed } = stored.revisions,
lowerRev = active.list[ offset - 1 ],
higherRev = active.list[ offset + rangeSize ],
priorRev = fromRecent ? higherRev : lowerRev,
followingRev = fromRecent ? lowerRev : higherRev,
// If the next revision isn't available, use the timestamp from
// the continue token, which is the same.
// However, if the list is complete, the continue token is no
// longer valid.
continueTimestamp = !completed && getTimeStamp( active.continueToken ),
priorTimestamp = priorRev ? priorRev.timestamp : ( fromRecent && continueTimestamp ),
followingTimestamp = followingRev ? followingRev.timestamp : ( !fromRecent && continueTimestamp );
return Promise.all( [
getLogsForRange( stored.protectLogs, priorTimestamp, followingTimestamp ),
getLogsForRange( stored.deleteLogs, priorTimestamp, followingTimestamp )
.then( deleteLogs => deleteLogs
// Don't show deletions of individual revisions in the history.
.filter( log => log.action !== 'revision' )
),
getLastSeen()
] ).then( ( [ protectLogs, deleteLogs, lastSeen ] ) => {
return protectLogs
.concat( deleteLogs )
.concat( lastSeen )
// Sort chronologically.
.sort( ( log1, log2 ) => log1.timestamp < log2.timestamp ? 1 : -1 );
} );
} ),
/*
// Add revision tags.
// Doesn't work. Tags are inaccessible without running repeated action:parses or scraping the history html.
// mw.message.parse doesn't work for {{Mediawiki:}} transclusions, and loadMessagesIfMissing doesn't
// get dependencies anyway
// Ideally there would just be a parsedtags option in action=revisions...
revisionsPromise
.then( revs => {
var allTags = [];
revs.forEach( rev => rev.tags && rev.tags.forEach( tag =>
allTags.indexOf( tag ) === -1 && allTags.push( tag )
) );
console.log( allTags , 33 )
return new mw.Api().loadMessagesIfMissing(
allTags.map( tag => 'tag-' + tag )
);
} ),
*/
// If the basics and dependencies haven't yet been loaded, load them.
loadedInitData || getInitData()
] ).then( ( [ compares, logs ] ) => {
compares.forEach( compare => {
compare.deleteLog = revDeleteLogs[ compare.revid ];
} );
busy = false;
return [ compares, logs ];
} );
}
/**
* Get dependencies, messages, and page's edit count.
* @return {Promise}
*/
function getInitData() {
return Promise.all( [
// Get edit count.
// Doing this is a mess, involving scraping action=info. See T19993 for making a proper API for it.
// NOTE: If date of first edit is needed, can be reached from #mw-pageinfo-firsttime here (as English date string).
fetch( new mw.Title( mw.config.get( 'wgPageName' ) ).getUrl( { action:'info', uselang: 'en' } ) )
.then( x => x.text() )
.then( html => {
var frag = ( new DOMParser() ).parseFromString( html, 'text/html' );
editcount = +frag.querySelector( '#mw-pageinfo-edits td + td' ).innerText.replace( /,/g, '' );
} ),
// Load dependencies
mw.loader.using( [
'mediawiki.diff.styles',
'mediawiki.language.months'
] ),
// Load Mediawiki messages
( new mw.Api() ).get( {
action: 'query',
meta: 'allmessages',
amlang: mw.config.get( 'wgUserLanguage' ),
ammessages: [
'minoreditletter', 'boteditletter',
'recentchanges-label-minor', 'recentchanges-label-bot',
'talkpagelinktext', 'contribslink',
'editundo', 'tooltip-undo', 'thanks-thank', 'thanks-thank-tooltip',
'rev-deleted-user',
'dellogpage',
'revdelete-content-hid', 'revdelete-summary-hid', 'revdelete-uname-hid',
'revdelete-content-unhid', 'revdelete-summary-unhid', 'revdelete-uname-unhid',
'tag-list-wrapper', 'tag-canned_edit_summary',
'diff-paragraph-moved-toold', 'diff-paragraph-moved-tonew'
].join( '|' ),
maxage: 60 * 60 * 24 * 30,
smaxage: 60 * 60 * 24 * 30
} ).then( messages => {
messages.query.allmessages.forEach( message => {
if ( 'missing' in message ) {
// TODO: Something? Not sure. Absence of certain messages on some wikis can cause problems...
mw.messages.set( message.name, '<' + message.name + '>' );
} else {
mw.messages.set( message.name, message[ '*' ] );
}
} );
} )
] ).then( () => {
loadedInitData = true;
} );
}
/**
* Determine whether there is room to pan a certain direction.
* Negative is later/more recent, positive is earlier/further back.
*
* @param {Number} dir Which direction to check. 1 -> earlier, -1 -> more recent.
* @return {Boolean}
*/
function canPan( dir ) {
return !busy && ( dir === 1 ?
fromRecent ?
offset + rangeSize < editcount :
offset > 0 :
fromRecent ?
offset > 0 :
offset + rangeSize < editcount );
}
/**
* @param {Number} dir 1 -> earlier, -1 -> more recent
*/
function pan( dir ) {
var _dir = fromRecent ? dir : -dir;
if ( _dir === 1 ) {
offset = Math.max( offset + rangeSize * _dir, 0 );
cancelShift();
} else {
cancelShift();
offset = Math.max( offset + rangeSize * _dir, 0 );
}
}
/**
* Pan all the way to the oldest or most recent edit.
* @param {Number} dir Which edge to pan to. 1 -> earliest, -1 -> most recent.
*/
function panToEdge( dir ) {
offset = 0;
fromRecent = dir === -1;
cancelShift();
stored.revisions.setToEdge( dir );
stored.protectLogs.setToEdge( dir );
stored.deleteLogs.setToEdge( dir );
}
/**
* Zoom in or out, to display more or fewer edits at once.
* @param {Number} dir Whether to zoom in or out. 1 -> zoom in, -1 -> zoom out.
*/
function zoom( dir ) {
cancelShift();
rangeSize = Math.min( Math.floor( rangeSize * ( dir === 1 ? 0.5 : 2 ) ) || 1, 500 );
}
function canZoom( type ) {
return !busy && ( type === 1 ? rangeSize !== 1 : rangeSize < 500 && rangeSize < editcount );
}
/**
* When showing only specific rows, display some more edits.
*/
function displayMore() {
var amount = 50;
// Should there be different "actual (backend-used) rangeSize" / "user-apparent rangeSize"?
if ( rangeSize >= 500 ) {
return false;
} else if ( offset + rangeSize >= editcount ) {
// Ran into the edge. Can we expand in the other direction?
if ( offset === 0 ) {
return false;
} {
offset = Math.max( 0, offset - amount );
}
} else {
rangeSize += amount;
}
return true;
}
// TODO.
function selectRows( g1 , g2 ) {
// There are two ways to identify rows.
// 1. Record column and index.
// 2. Record element, if cached properly. (Or column and element, to speed up performance.)
// Store and return "<tr>" elements generated from particular compares, used
// as boundaries.
// When expanding based on row selection, keep going until either we hit the
// max, or everything in the range has its first change type = 'add'.
// Does this need to be more than one function? Could have one arg, some encapsulated data...
// Need to know whether there's room to scroll left/right, though... Give through return params?
if ( g1 || g2 ) {
preRowFilterRangeSize = rangeSize;
} else {
cancelShift();
}
}
function cancelShift() {
if ( preRowFilterRangeSize ) {
rangeSize = preRowFilterRangeSize;
preRowFilterRangeSize = false;
}
}
/**
* Set the range to between two particular edits, and return the diff
* between them.
* @return {Promise}
*/
function selectCols( rev1, rev2 ) {
var list = stored.revisions.active.list,
[ i1, i2 ] = [ rev1, rev2 ].map( revid => list.findIndex( revision => revision.revid === revid ) );
offset = fromRecent ? i2 : i1;
rangeSize = fromRecent ? i1 - i2 + 1 : i2 - i1 + 1;
console.log( i1, i2 );
return getCompare( false, list[ i1 ], list[ i2 ] );
}
/**
* "1 - 50 of 123"
* @return {String}
*/
function getPosition() {
return mw.msg(
'HV-Position',
fromRecent ?
offset + 1 :
Math.max( 1, editcount - offset + 1 - rangeSize ),
fromRecent ?
( editcount ? Math.min( offset + rangeSize, editcount ) : '?' ) :
( editcount - offset ),
( editcount || '?' )
);
}
return {
getData,
pan,
panToEdge,
zoom,
selectRows,
selectCols,
canPan,
canZoom,
displayMore,
isBusy: () => busy,
getPosition
};
} )();
/**
* @param {Object} compare
* @param {HTMLTableElement} [revertedFrom] If this edit reverted the edit
* immediately before it, the diff of the reverted edit.
*
* @return {jQuery} return.$table The diff table
* @return {jQuery} return.$trs
* @return {jQuery} return.$delLogElem
*/
function getCompareElement( compare, revertedFrom ) {
function getElements() {
var $table,
$trs,
$delLogElem,
missingcontent = compare.missingcontent,
isRevert = !!revertedFrom;
if ( isRevert ) {
if ( compare.$rvTable ) {
( { $rvTable: $table, $rvTrs: $trs } = compare );
} else {
$table = compare.$rvTable = createRevertElement( $( revertedFrom ) );
$trs = compare.$rvTrs = $table.find( 'tr' );
}
} else {
if ( compare.$table ) {
// Get cached element.
( { $table, $trs } = compare );
} else {
if ( missingcontent ) {
$table = createEmptyTable();
$trs = $( [] );
} else {
$table = $( '<table class="diff diff-contentalign-left" data-mw="interface">' +
'<colgroup><col class="diff-marker"><col class="diff-content"><col class="diff-marker"><col class="diff-content"></colgroup><tbody></tbody></table>'
);
$table.find( 'tbody' ).html( compare.compare[ '*' ] );
$trs = $table.find( 'tr' );
}
// Cache
compare.$table = $table;
compare.$trs = $trs;
}
}
// Add revision deletion log.
if ( compare.$delLogElem ) {
// From cache
$delLogElem = compare.$delLogElem;
} else if ( compare.deleteLog ) {
// Build log element.
$delLogElem = compare.$delLogElem = createDeletionLogElement( compare.deleteLog );
}
return { $table, $trs, $delLogElem };
}
function createEmptyTable() {
var $table = $( '<table><tbody><tr><td></td></tr></tbody></table>' );
$table.find( 'td' ).text( mw.msg( 'HV-DeletedRev') );
return $table;
}
/**
* @return {jQuery}
*/
function createDeletionLogElement( deleteLog ) {
return $( '<div>' ).append(
$( '<h3>' ).text( mw.msg( 'dellogpage' ) ),
$( '<ul>' ).append( deleteLog.map( log => {
var types = [];
// Find types of visibility changes, eg hidden content, username
[
[ 'content', 'content' ],
[ 'comment', 'summary' ],
[ 'user', 'uname' ]
].forEach( ( [ paramKey, messageKey ] ) => {
var visibilityChangeMessage = paramKey in log.params.new ?
messageKey + '-hid' : paramKey in log.params.old ?
messageKey + '-unhid' : '';
if ( visibilityChangeMessage ) {
types.push( mw.msg( 'revdelete-' + visibilityChangeMessage ) );
}
} );
return $( '<li>' ).append(
// Date
$( '<span>' ).text( formatTimestamp( new Date( log.timestamp ) ) ),
' ',
// User link
$( '<a>' ).text( log.user ).attr( 'href', new mw.Title( log.user, 2 ).getUrl() ),
' - ',
$( '<span>' ).text( types.join( ', ' ) ),
' ',
// Log summary
log.parsedcomment && $( '<span>' ).addClass( 'comment' ).html( '(' + log.parsedcomment + ')' )
);
} ) )
);
}
/**
* Build a diff table equivalent to a revert of another edit.
*
* Revert diffs made by the normal diff engine are, unfortunately, not
* always simmetrical from the original diff. See
* https://wiki.riteme.site/w/index.php?diff=355931478 and predecessor
* for example. So, we build the mirror diff right here.
*
* @param {jQuery} $oldElem
* @return {jQuery}
*/
function createRevertElement( $oldElem ) {
var $elem = $oldElem.clone( true ),
addClass = 'diff-addedline',
delClass = 'diff-deletedline',
mtClass = 'mw-diff-movedpara-left',
mfClass = 'mw-diff-movedpara-right',
$adds = $elem.find( '.diff-addedline' ),
$dels = $elem.find( '.diff-deletedline' ),
$iAdds = $elem.find( 'ins' ),
$iDels = $elem.find( 'del' ),
$lineNos = $elem.find( '.diff-lineno + .diff-lineno' ),
$mods = $elem.find( '.' + delClass + ' ~ .' + addClass ),
$empties = $elem.find( '.diff-empty' ),
markerText = { del: '−', add: '+', mt: '⚫', mf: '⚫' };
// Swap "added" and "deleted" classes.
$adds.removeClass( addClass ).addClass( delClass );
$dels.removeClass( delClass ).addClass( addClass );
// Replace ins's with del's and vice-versa.
[ [ $iAdds, '<del>' ], [ $iDels, '<ins>' ] ].forEach( ( [ $group, tag ] ) => {
$group.each( ( i, inlineChange ) => {
var $inlineChange = $( inlineChange );
$inlineChange.replaceWith(
// Duplicate original element, but with a different tag name.
$( tag )
.append( $inlineChange.contents() )
.addClass( inlineChange.className )
);
} );
} );
// Swap line numbers.
$lineNos.each( ( i, lineNo ) => {
lineNo.parentNode.appendChild( lineNo.previousElementSibling );
} );
//
$empties.each( ( i, empty ) => {
var parent = empty.parentNode,
$marker = $( parent ).find( '.diff-marker' ),
$move = $marker.find( 'a' );
if ( empty.nextElementSibling ) {
// Add -> Del
parent.appendChild( empty );
if ( $move.length ) {
$move.attr( 'title', mw.msg( 'diff-paragraph-moved-tonew' ) );
$move.removeClass( mfClass ).addClass( mtClass );
$move.text( markerText.mt );
} else {
$marker.text( markerText.del );
}
} else {
// Del -> Add
parent.insertBefore( empty, parent.firstChild );
if ( $move.length ) {
$move.attr( 'title', mw.msg( 'diff-paragraph-moved-toold' ) );
$move.removeClass( mtClass ).addClass( mfClass );
$move.text( markerText.mf );
} else {
$marker.text( markerText.add );
}
}
} );
// For modified lines, swap the old and new versions, then replace dels with ins's.
$mods.each( ( i, mod ) => {
var parent = mod.parentNode,
children = parent.children;
parent.insertBefore( children[ 3 ], children[ 1 ] );
parent.appendChild( children[ 2 ] );
} );
return $elem;
}
return getElements( compare );
}
/**
* Process the API results into a more usable format, ordered by rows and columns.
*
* @param {Array} compares List of 'compares', from the action=compare API.
* @return {Array} return.changeCols Each changeCol representing a single edit.
* @return {Array} return.changeRows Each changeRow representing a line on the page.
*/
function processDiffs( [ ...compares ], filterRowSettings ) {
var
/**
* List of all rows/lines. These are 1-indexed.
*
* @property {Array} changeRows[].changes List of changes to the row/line.
* @property {Array} changeRows[].headers List of time periods in which
* the row/line has contained a header, along with the text of the header.
* @property {string} changeRows[].headers[].text Contents of the header.
* @property {number} changeRows[].headers[].start Index of the first edit
* in which the header appeared.
* @property {number} changeRows[].headers[].end Index of the edit in
* which the header was deleted.
* @property {number} changeRows[].height Height in pixels of the row, as it appears on
* the canvas.
* @property {number} changeRows[].Y Distance, in pixels, between the row and the top
* of the canvas.
*/
changeRows = [],
/**
* All columns/edits. (0-indexed.)
*
* @property {Array} changeCols[].changes List of changes in this edit.
* @property {string} changeCols[].user Username of the author of the edit.
* @property {number} changeCols[].width
* @property {number} changeCols[].X
*
*/
changeCols = [],
// Map of which edits are reverts to earlier edits, or reverted by later edits..
reverts = compares.map( () => ({}) );
/**
* Returns true if the last change in the row is a deletion.
* @param {Object} changeRow
* @param {Number} [upToColumn] Don't count this column as part of the row.
*/
function endsInDeletion( changeRow, upToColumn ) {
if ( !changeRow ) {
return false;
}
var changes = changeRow.changes,
lastChange = changes[ changes.length - 1 ];
if ( lastChange && lastChange.type === 'del' && lastChange.col !== upToColumn ) {
return true;
} else {
return false;
}
}
/**
* Insert change into changeRows, in the appropriate row. (Also set certain
* properties of the change.)
* @param {Number} row
* @param {Object} change
*/
function addChange( row, change ) {
var rChanges, lastChange,
newRow = { changes: [] };
row = skipDeletedRows( row, change.col, change.type === 'add' );
if ( change.type === 'add' ) {
// This is a new row. Insert, don't modify an existing row's history,
// unless there's an empty gap (deletion) available on the same spot.
if ( changeRows.length < row ) {
// Insert. Splice stops at the end of an array, so use direct assignment.
changeRows[ row ] = newRow;
} else if ( endsInDeletion( changeRows[ row ] ) ) {
// There's a gap. Add to the end.
// If there are several empty insertion points, and one had
// content matching the new addition, prioritize that line.
for ( let i = row, lastChange; endsInDeletion( changeRows[ i ] ); i++ ) {
lastChange = changeRows[ i ].changes.slice( -1 )[ 0 ];
if ( change.addText && lastChange.delText === change.addText ) {
// Content matches. looks like a clean revert of a prior deletion.
row = i;
lastChange.reverted = true;
change.revert = true;
break;
}
}
} else {
// Insert.
changeRows.splice( row, 0, newRow );
}
} else {
// Create row if not yet created.
rChanges = ( changeRows[ row ] = changeRows[ row ] || newRow ).changes;
// Check reverts in mods
lastChange = rChanges[ rChanges.length - 1 ];
if ( lastChange ) {
// If the change is the reverse of the previous change to this row,
// mark the changes as revert/reverted.
if (
lastChange.type === 'mod' && change.type === 'mod'
// ||
// // Unsure of whether to count this. All add->dels are "reverts", sort of.
// lastChange.type === 'add' && change.type === 'del'
) {
if ( lastChange.delText === change.addText ) {
lastChange.reverted = true;
change.revert = true;
// lastChange.revertX = change;
}
}
}
}
// Add change to row.
changeRows[ row ].changes.push( change );
change.changeRow = changeRows[ row ];
}
/**
* Skip over rows that have been removed in a prior edits, to maintain line
* number consistency.
*
* @param {Number} row Line within the current version of the page.
* @return {Number} row Equivalent line of changeRows, after skipping those
* rows since deleted.
*/
function skipDeletedRows( row, upToColumn, allowEndOnEmpty ) {
changeRows.forEach( ( changeRow, i ) => {
if ( i < row && endsInDeletion( changeRow ) ) {
row++;
}
} );
// ?
if ( allowEndOnEmpty ) {
while ( endsInDeletion( changeRows[ row ] ) ) {
// This is endsInDeletion's only use of the second arg... TODO: Simplify.
if ( endsInDeletion( changeRows[ row ], upToColumn ) ) {
// Go on top of the old deleted row, instead of splicing new row in.
// TODO: This isn't always working. See [test]'s giant revert, not matching up.
// Issue: It can't actually tell they're the same lines.
// words1 words1 words1 words1
// words2 -> -> -> words3 <- WRONG SPACE, simple revert shuffles rows
// words3 -> words3 -> ->
// words4 words4 words4 words4
//
// Basically, have to make special exception for reverts.
// Can search prior deletions for a row that begins with the right text?
// How about: { [ text ]: col / columnNumber } ?
// Note: Sometimes mods are split by mw into del > add, in that order.
return row;
} else {
// This row is occupied by a deletion from the same edit. Skip past it.
row++;
}
}
} else {
while ( endsInDeletion( changeRows[ row ] ) ) {
row++;
}
}
return row;
}
/**
* @param {String} text
* @return {String|undefined}
*/
function getHeaderText( text ) {
var match = text.match( /^==\s*([^=].*)==$/ );
// Trim whitespace and strip links
return match && match[ 1 ].trimRight().replace( /\[\[(?:[^\|]*\|)?([^\]]+)\]\]/g, '$1' );
}
// TODO: Reverse in apiHandler instead. (Without breaking the logs.)
compares = compares.reverse();
if ( compares.length === 0 ) {
// throw new Error( 'HistoryView - processDiffs missing argument length ' );
// ...shift to the side?
return { changeRows, changeCols };
}
// Track reverts, by checking for all edits that match sha1s with an earlier edit.
( () => {
// Loop through compares, from most recent to oldest.
for ( var i = compares.length - 1, shaList = compares.map( x => x.sha1 ); i > 0; i-- ) {
const sha = shaList[ i ],
// Search for latest prior duplicate.
matchingShaIndex = shaList.lastIndexOf( sha, i - 1 );
if ( sha !== '?' && matchingShaIndex !== -1 ) {
// Up to but not including matchingShaIndex are reverted.
for ( let ii = matchingShaIndex + 1; ii < i; ii++ ) {
reverts[ ii ].revertedBy = i;
}
reverts[ i ].revert = true;
reverts[ i ].revertTo = matchingShaIndex + 1;
// Skip to dup.
i = matchingShaIndex + 1;
}
}
} )();
// Build changeCols, changeRows
compares.forEach( ( compare, i ) => {
var row = 0,
{ $table, $trs, $delLogElem } = getCompareElement(
compare,
// Check if immediate revert to prior edit
reverts[ i ].revertTo === i - 1 &&
// ...and that the revision wasn't deleted.
!compare.missingcontent &&
// Check again on the current edit. (This can be necessary bc of caching issues.)
!changeCols[ i - 1 ].missingcontent &&
// Pass the element to flip, if revert.
changeCols[ i - 1 ].elem
),
// List of changes that occur in this edit/column.
colGroup = [],
lastColGroup = i && changeCols[ i - 1 ].changes,
// Used by matchMovedParagraphs.
movedParagraphsIds = {},
// List of paragraph moves, populated by matchMovedParagraphs.
movedParagraphs = [];
changeCols.push( {
changes: colGroup,
user: compare.user,
anon: compare.anon,
revid: compare.revid,
priorrevid: compare.compare.fromrevid,
comment: compare.compare.tocomment,
parsedcomment: compare.compare.toparsedcomment,
minor: compare.minor,
bot: compare.bot,
userhidden: compare.userhidden,
missingcontent: compare.missingcontent,
// TODO: Consider renaming to revertedBy.
reverted: reverts[ i ].revertedBy,
revert: reverts[ i ].revert,
revertTo: reverts[ i ].revertTo,
movedParagraphs,
X: null, // Defined later on.
baseWidth: 1, // Defined later on.
width: null, // Defined later on.
elem: $table[ 0 ],
delLogElem: $delLogElem && $delLogElem[ 0 ],
timestamp: compare.timestamp
} );
/**
* Populate movedParagraphs with data about which lines were moved where
* during this edit.
*
* @param {HTMLTableCellElement} moveBlock The cell containing the moved
* content.
* @param {HTMLAnchorElement} moveLink The link pointing to the source or
* target of the moved paragraph.
* @param {Object} change
* @param {Boolean} to True if the line was moved here, false if it was
* moved from here to somewhere else.
*/
function matchMovedParagraphs( moveBlock, moveLink, change, to ) {
var moveId = moveBlock.firstChild.firstChild.name,
moveTarget = moveLink.firstChild.getAttribute( 'href' ).substr( 1 ),
otherChange = movedParagraphsIds[ moveTarget ];
if ( otherChange ) {
var move = to ? { from: [ otherChange ], to: [ change ] } : { from: [ change ], to: [ otherChange ] };
move.from[ 0 ].moveTo = move;
move.to[ 0 ].moveFrom = move;
movedParagraphs.push( move );
} else {
movedParagraphsIds[ moveId ] = change;
}
}
/**
* @param {HTMLTableRowElement} tr
* @return {Object} change
* @return {'linenumber'/'context'/'add'/'del'/'mod'} return.type
* For edited lines (type = 'add', 'del', 'mod'):
* @return {string} return.addText The text content of this line after the edit.
* @return {string} return.delText The text content of this line before the edit.
* @return {number} return.add Number of characters of added text.
* @return {number} return.del Number of characters of deleted text.
* For context lines (type = 'context'):
* @return {string} return.cText The text conten of this line.
* For line number lines (type = 'linenumber'):
* @return {number} return.line Line number
*/
function extractChangeFromDom( tr ) {
var change = { elem: tr };
if ( tr.firstElementChild.className === 'diff-lineno' ) {
// LINE NUMBER
change.type = 'linenumber';
// Can't just match \d. Big numbers have commas.
// Note that this doesn't work for languages that don't use Hindu-Arabic numerals.
change.line = +tr.lastElementChild.innerText.match( /[\d,]+/g )[ 0 ].replace( /,/g, '' );
} else if ( tr.lastElementChild.className === 'diff-context' ) {
// CONTEXT - NO CHANGE TO ROW
change.type = 'context';
change.contextText = tr.lastElementChild.innerText;
} else if ( tr.firstElementChild.className === 'diff-empty' ) {
// ADDED LINE
change.type = 'add';
change.addText = tr.lastElementChild.innerText;
change.add = change.addText.length || 1;
change.del = 0;
if ( tr.children[ 1 ].firstChild.className === 'mw-diff-movedpara-right' ) {
matchMovedParagraphs(
tr.lastElementChild,
tr.children[ 1 ],
change,
true
);
}
} else if ( tr.lastElementChild.className === 'diff-empty' ) {
// REMOVED LINE
change.type = 'del';
change.delText = tr.children[ 1 ].innerText;
change.add = 0;
change.del = change.delText.length || 1;
if ( tr.firstElementChild.firstChild.className === 'mw-diff-movedpara-left' ) {
matchMovedParagraphs(
tr.children[ 1 ],
tr.firstElementChild,
change,
false
);
}
} else {
// MODIFIED LINE
change.type = 'mod';
change.delText = tr.children[ 1 ].innerText;
change.addText = tr.children[ 3 ].innerText;
change.add =
Array.from( tr.querySelectorAll( 'ins' ) ).reduce( ( acc, el ) => acc + el.innerText.length, 0 );
change.del =
Array.from( tr.querySelectorAll( 'del' ) ).reduce( ( acc, el ) => acc + el.innerText.length, 0 );
}
return change;
}
/**
* Add changes in headers to the headers array.
*/
function addHeaderData( change, row, col ) {
var oldHeader,
newHeader,
// Only available for changes to the content
changeRow = change.changeRow;
// All L2 headers ("==Content==") are recorded, including their
// location, contents and time of addition and removal.
if ( change.type === 'context' ) {
var headerText = getHeaderText( change.contextText );
if ( headerText ) {
var nRow = skipDeletedRows( row, col, false );
// If this row hasn't been seen before, fill in header data.
changeRows[ nRow ] = changeRows[ nRow ] || { changes: [], headers: [ { start: 0, text: headerText } ] };
}
} else {
if ( change.type !== 'add' ) {
oldHeader = getHeaderText( change.delText );
}
if ( change.type !== 'del' ) {
newHeader = getHeaderText( change.addText );
}
if ( oldHeader ) {
if ( !changeRow.headers ) {
// We have no prior record of this (now-removed) header's existence.
// It must have been added prior to the first revision shown here.
// Add removed header data.
changeRow.headers = [ { text: oldHeader, start: 0 } ];
}
if ( oldHeader !== newHeader ) {
// Unless perfectly matching the new header (eg, whitespace-only
// change), the old header has now ended.
changeRow.headers.slice( -1 )[ 0 ].end = col;
}
}
// Update for added headers.
if ( newHeader && newHeader !== oldHeader ) {
changeRow.headers = changeRow.headers || [];
let lastHeader = changeRow.headers.slice( -1 )[ 0 ];
if ( !oldHeader && lastHeader && lastHeader.end === col - 1 && lastHeader.text === newHeader ) {
// Re-adding a header that was just deleted last edit.
// Consider this the same header, and continue it.
delete lastHeader.end;
} else {
// Adding a new header.
changeRow.headers.push( { text: newHeader, start: col } );
}
}
}
}
$trs.each( ( trI, tr ) => {
var change = extractChangeFromDom( tr );
if ( change.type === 'linenumber' ) {
// LINE NUMBER
// The row contains the text "Line [some number]:".
// Skip to the line shown.
row = change.line;
} else if ( change.type === 'context' ) {
// CONTEXT - NO CHANGE TO ROW
// If the line contains a header, deal with that.
addHeaderData( change, row, i );
row++;
} else {
// The row has been changed in some way, either an addition, a
// deletion, or a change in existing content.
var isImmediateRevert = reverts[ i ].revertTo === i - 1;
change.col = i;
// Check if revert
if ( isImmediateRevert ) {
// Move to addChange?
let matchingChange = lastColGroup[ colGroup.length ];
// Reverts are, unfortunately, not always simmetrical. See
// https://wiki.riteme.site/w/index.php?diff=355931478 and predecessor
// for exaample.
// Also, X\nY->Y\nX is X moving two rows down, but the revert is Y
// moving two rows down.
// To solve this, in these cases the element for the revert is built
// by createRevertElement to be a mirror of the element for the
// reverted edit.
if ( matchingChange ) {
// Is this redundant?
change.revert = true;
matchingChange.reverted = true;
change.changeRow = matchingChange.changeRow;
matchingChange.changeRow.changes.push( change );
} else {
// The chart will almost certainly be messed up somewhat. Not fixable.
// TODO: Give up matching for the rest of the column. Otherwise
// the unsyncing breaks things.
//
// ...Is this still possible, since the reverts are now constructed?
addChange( row, change );
}
} else {
addChange( row, change );
}
// If a header has been added, deleted, or modified, deal with that.
addHeaderData( change, row, i );
if ( change.type !== 'del' ) {
// Continue to next row.
row++;
}
colGroup.push( change );
}
} );
} );
// For selectRows
if ( filterRowSettings ) {
// Filtering out all rows outside a range specified by filterRowSettings.
// This is set by selectAreas.
// The top and bottom rows are the first rows outside the shown content.
console.log( 99, filterRowSettings );
// filterRowSettings stores boundaries as the <tr> elements in the diffs.
// Find those elements, mark the boundaries, and remove everything outside them.
// TODO: Should work for from top to bottom.
// TODO: Maintain row filter during zoom and even scroll, ideally. Certainly during further select-filter.
// These boundaries are to be the first rows outside the shown content.
// The boundary rows themselves will not be shown.
let upperBoundary = !filterRowSettings.top && -1,
lowerBoundary = false;
changeRows.forEach( ( { changes }, row ) => {
if ( upperBoundary === false ) {
// We're above the upper boundary. Remove from the columns.
changes.forEach( change => {
changeCols[ change.col ].changes.shift();
} );
// Check if we've arrived at the upper boundary.
upperBoundary = changes.some( change => change.elem === filterRowSettings.top ) && row;
} else {
if ( lowerBoundary === false ) {
// Did we arrive at the lower boundary?
lowerBoundary = changes.some( change => change.elem === filterRowSettings.bottom ) && row;
}
if ( lowerBoundary !== false ) {
// We're past the lower boundary. Remove changes from their changeCols.
// (Might technically not be the same change, but so long as we have
// the right amount removed from the end it amounts to the same thing.)
changes.forEach( change => {
changeCols[ change.col ].changes.pop();
} );
}
}
} );
// Remove all rows outside the boundaries.
changeRows = changeRows.slice( upperBoundary + 1, lowerBoundary || changeRows.length );
while ( changeRows.length && changeRows[ changeRows.length - 1 ] === undefined ) {
changeRows.pop();
}
// We probably have a bunch of empty changeCols now. Hide them.
// changeCols = changeCols.filter( changeCol => changeCol.changes.length );
changeCols.forEach( changeCol => {
if ( changeCol.changes.length === 0 ) {
changeCol.hidden = true;
}
} );
}
return { changeRows, changeCols };
}
/**
* Format rows and columns for display purposes.
* Set visible sizes and positions.
*/
function formatDiffs( changeRows, changeCols ) {
// Set row heights, Y positions, etc.
( Y => {
var totalHeight,
heightPerBit,
// rows are 1-indexed
lastRow = 1,
lastChangeRow,
minRowHeight = 0;//20,
changeRows.forEach( ( changeRow, row ) => {
// TODO: Fill in gaps, with standard length rows for "untouched".
// How large is the largest change in this row?
var maxChange = changeRow.changes.reduce( ( acc, change ) => (
// Don't expand lines on account of reverted changes.
changeCols[ change.col ].revert || changeCols[ change.col ].reverted || change.revert || change.reverted
) ? acc || 1 : Math.max( acc, change.add + change.del ), 0 );
// Test. Unsure.
// maxChange = Math.min( maxChange, 2000 );
// maxChange = Math.min( maxChange, 1200 );
// Resize everything vertically to fit into the row, for shrunken rows.
changeRow.changes.forEach( change => {
if ( change.add + change.del > maxChange ) {
var shrinkFactor = ( change.add + change.del ) / maxChange;
change.add = change.add / shrinkFactor;
change.del = change.del / shrinkFactor;
}
} );
// Height - Largest change to occur in this row, in any column.
changeRow.height = maxChange;
Y += Math.max( ( row - lastRow ) * minRowHeight, lastChangeRow ? lastChangeRow.height : 0 );
changeRow.Y = Y;
lastChangeRow = changeRow;
lastRow = row;
// Y += maxChange;
} );
totalHeight = changeRows.length ?
changeRows[ changeRows.length - 1 ].Y + changeRows[ changeRows.length - 1 ].height :
1;
heightPerBit = changeAreaHeight / totalHeight;
changeRows.forEach( changeRow => {
changeRow.Y *= heightPerBit;
changeRow.height *= heightPerBit;
changeRow.changes.forEach( change => {
change.add *= heightPerBit;
change.del *= heightPerBit;
} );
} );
} )( 0 );
// Set column widths, X positions.
( X => {
var lastChangeCol,
totalWidth,
colsWithSameUser = [];
// Process movedParagraphs to group adjacent moves that have similarly
// adjacent targets.
function groupAdjacentMoves( changeCol ) {
function getRow( change ) {
return changeRows.indexOf( change.changeRow );
}
let { movedParagraphs } = changeCol;
for ( let i = 1; i < movedParagraphs.length; i++ ) {
let move = movedParagraphs[ i ],
lastMove = movedParagraphs[ i - 1 ];
if (
lastMove &&
[ 'from', 'to' ].every( dir => {
var last = lastMove[ dir ].slice( -1 )[ 0 ],
cur = move[ dir ][ 0 ],
[ lastIndex, curIndex ] = [ last, cur ].map( change => changeCol.changes.indexOf( change ) ),
[ lastRow, curRow ] = [ last, cur ].map( getRow ),
interveningRowsCount = curRow - lastRow - 1,
interveningBlankRows = 0;
if ( curRow > lastRow && changeCol.changes.slice( lastIndex + 1, curIndex ).every( change => {
if ( !change.addText && !change.delText ) {
interveningBlankRows++;
// Allow blanks in between the rows.
return true;
} else {
// There's a row in between that has actual content in it.
// Don't group.
return false;
}
} ) ) {
return interveningRowsCount === interveningBlankRows;
}
} )
) {
// Merge the paragraph move blocks.
move.from[ 0 ].moveTo = lastMove;
move.to[ 0 ].moveFrom = lastMove;
lastMove.from.push( move.from[ 0 ] );
lastMove.to.push( move.to[ 0 ] );
movedParagraphs.splice( i--, 1 );
}
}
}
// TODO: Should also shrink bot edits?
// TODO: Shrink sequence of reverted edits to min size per user.
// How to handle a sequence of edits by one user, only some of which are reverted? Shrink the reverted group down to min?
changeCols.forEach( changeCol => {
// Shrink minor edits.
if ( changeCol.minor || changeCol.reverted || changeCol.revert ) {
// changeCol.baseWidth /= 2;
changeCol.baseWidth = 0.5;
}
} );
// Shrink and lighten bar when multiple edits by same user.
changeCols.forEach( changeCol => {
if ( changeCol.hidden ) {
changeCol.baseWidth = 0;
} else {
var isRevert = changeCol.reverted || changeCol.revert || changeCol.missingcontent,
// Different user than previous edit.
newUser = changeCol.user !== ( lastChangeCol || {} ).user;
if ( !newUser ) {
changeCol.baseWidth = lastChangeCol.baseWidth = 0.5;
// Make dividing bars lighter between multiple edits by same user.
changeCol.barColor = '#EEEEEE';
} else {
changeCol.showUser = true;
changeCol.barColor = '#DDDDDD';
}
if ( !isRevert || newUser ) {
if ( colsWithSameUser.length ) {
colsWithSameUser.forEach( col => {
// Problem: A revert alone doesn't even count as minor with this, does it?
// Other problem: Can be reset to 0.5 by lastChangeCol above.
col.baseWidth /= colsWithSameUser.length;
} );
colsWithSameUser.splice( 0 );
}
}
if ( isRevert ) {
colsWithSameUser.push( changeCol );
}
lastChangeCol = changeCol;
}
} );
totalWidth = changeCols.reduce( ( acc, changeCol ) => acc + changeCol.baseWidth, 0 );
changeCols.forEach( changeCol => {
changeCol.X = X;
X +=
changeCol.width = fullWidth * changeCol.baseWidth / totalWidth;
groupAdjacentMoves( changeCol );
// Set position of paragraph move arrows.
var mostOverlappingArrows = 0;
/**
* Check whether any part of the two arrows covers the same vertical
* area, such that one would need to be pushed to the side for the
* arrows to be legible.
* @return {Boolean}
*/
function hasOverlap( arrow1, arrow2 ) {
return [
arrow1.fromY > arrow2.fromY,
arrow1.fromY > arrow2.toY,
arrow1.toY > arrow2.fromY,
arrow1.toY > arrow2.toY
].some( ( comp, i, all ) => comp !== all[ 0 ] );
}
changeCol.movedParagraphs.forEach( ( movedParagraph, i, allMoves ) => {
// Set Y positions for move arrows.
[ movedParagraph.fromY, movedParagraph.toY ] = [ movedParagraph.from, movedParagraph.to ].map( group => {
var lastChange = group.slice( -1 )[ 0 ];
return ( group[ 0 ].changeRow.Y + lastChange.changeRow.Y + lastChange.add + lastChange.del ) / 2;
} );
// Set lanes for move arrows, which will be used to determine
// X positions later on.
var overlapping = allMoves.slice( 0, i ).filter( move => hasOverlap( movedParagraph, move ) );
// Keep checking lanes until we find one that isn't also occupied by
// a vertically-overlapping arrow, then insert this arrow there.
for ( var ii = 1; true; ii++ ) {
if ( overlapping.every( move => move.lane !== ii ) ) {
//
movedParagraph.lane = ii;
if ( ii > mostOverlappingArrows ) {
mostOverlappingArrows = ii;
}
break;
}
}
} );
changeCol.movedParagraphs.forEach( movedParagraph => {
movedParagraph.X = changeCol.width * movedParagraph.lane / ( mostOverlappingArrows + 1 );
movedParagraph.maxArrowWidth = changeCol.width / mostOverlappingArrows;
} );
} );
} )( 0 );
changeCols.forEach( changeCol => {
var { timestamp } = changeCol,
date = {
X: changeCol.X,
year: timestamp.getUTCFullYear(),
month: mw.language.months.abbrev[ timestamp.getUTCMonth() ],
day: timestamp.getUTCDate(),
barColor: changeCol.barColor
};
changeCol.date = date;
} );
}
/**
* @return {Array} logIcons
*/
function processLogs( logs ) {
function createIconElement( [ iconWidth, iconHeight, iconSvgCode ], color ) {
var scale = 0.1,
svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' ),
path = document.createElementNS( 'http://www.w3.org/2000/svg', 'path' );
svg.setAttribute( 'version', '1.1' );
svg.setAttribute( 'width', iconWidth * scale );
svg.setAttribute( 'height', iconHeight * scale );
path.setAttribute( 'd', iconSvgCode );
path.setAttribute( 'fill', color );
path.setAttribute( 'transform', `scale(${ scale })` );
svg.appendChild( path );
return svg;
}
var logIcons = [];
// Build logIcons
logs.reverse().forEach( log => {
var isUnprotect = log.action === 'unprotect',
iconType = [ 'unprotect', 'restore' ].includes( log.action ) ?
log.action :
log.type;
if ( log.type === 'protect' || log.type === 'delete' || log.type === 'lastSeen' ) {
var lastProtectLog = {},
timestamp = new Date( log.timestamp ),
details = log.params && log.params.details,
// Should this use whichever expiry is latest?
expiryTS = details ? +new Date( details[ 0 ].expiry ) : +new Date(), // If no expiry given, that means indefinite.
{ X = fullWidth } = changeCols.length ?
changeCols.find( col => +col.timestamp >= +timestamp ) || {} :
{ X: fullWidth / 2 },
end = changeCols.find( col => +col.timestamp > expiryTS ),
// To display in place of a diff. Should there be something here?
// Maybe a giant lock icon, to at least avoid a giant blank space?
elem = document.createElement( 'div' ),
Y = barsHeight / 2;
// TODO: Just store lastProtectLog instead, I think.
for ( let i = logIcons.length - 1; logIcons[ i ]; i-- ) {
if ( logIcons[ i ].type === 'protect' ) {
lastProtectLog = logIcons[ i ];
break;
}
}
if ( end !== changeCols[ 0 ] || !changeCols.length ) {
var color = ( isUnprotect ?
lastProtectLog && lastProtectLog.color :
details && [
// There are clearer colors at File:Move_protect.svg. TODO.
// '#0088FF', // Cascade
'#9dd7d8',
// '#CCCC00', // Full-protect
'#beac77',
'#999999', // Semi-protect
// '#006adc', // Extendedprotect
'#429eff',
'#f9dada', // Template protect
// '#00FF00', // Move-protect
// '#abc86e',
'#d6eaae',
// '#16b73b', // From svg
'#666666' // Everything else?
// The MW main page is actually breaking here. TODO: Fix.
][
Math.min( ...details.map( detail => {
var x = 'cascade' in detail ? 0 :
detail.type === 'edit' && detail.level === 'sysop' ? 1 :
detail.type === 'edit' && detail.level === 'autoconfirmed' ? 2 :
detail.type === 'edit' && detail.level === 'extendedconfirmed' ? 3 :
detail.type === 'edit' && detail.level === 'templateeditor' ? 4 :
detail.type === 'move' ? 5 : 6;
return x;
} ) )
]
) || ( {
'delete': '#9f3333',
'lastSeen': '#38d300'
}[ log.type ] ) ||
'#999999',
{ params = {} } = log;
elem.style.textAlign = 'center';
elem.title = {
'protect': mw.msg( 'HV-ProtectLog', log.user ),
'unprotect': mw.msg( 'HV-UnprotectLog', log.user ),
'delete': mw.msg( 'HV-DeleteLog', log.user ),
'restore': mw.msg( 'HV-RestoreLog', log.user ),
'move': mw.msg( 'HV-MoveLog', log.user ),
'lastSeen': mw.msg( 'HV-LastVisited', log.timestamp )
}[ iconType ];
elem.appendChild( createIconElement( icons[ iconType ], color ) );
// Vertically position icons.
for ( let i = logIcons.length - 1; logIcons[ i ] && logIcons[ i ].X === X; i-- ) {
logIcons[ i ].Y -= 15;
Y += 15;
}
// Does deletion cancel protection? I think sometimes?
if ( log.type === 'protect' && lastProtectLog.X + lastProtectLog.expiryX > X ) {
lastProtectLog.expiryX = X - lastProtectLog.X;
}
logIcons.push( {
X,
Y,
expiryX: log.type === 'protect' && !isUnprotect && ( ( end ? end.X : fullWidth ) - X ),
type: log.type,
user: log.user,
// This isn't as clear as the revision parsedcomment.
// Should comments be retrieved from action=revisions instead of by
// compare, and the logs matched up to revisions? Could work, maybe.
// TODO: Look into this.
// How to mark a log edit?
// * No change to page. Same sha.
// * Timestamp, username.
// Problem with that idea: Sometimes we don't have a log's revision.
// If started early, but expired after start, we have the log but not revision.
parsedcomment:
log.parsedcomment &&
( log.parsedcomment +
( params.description ? ' ' + params.description : '' ) ),
details: params.details,
color,
elem,
iconType,
timestamp,
isLog: true
} );
}
}
} );
return logIcons;
}
canvasDisplay = ( () => {
var displayContext = canvas.getContext( '2d' ),
backgroundCanvas = document.createElement( 'canvas' ),
backgroundContext = backgroundCanvas.getContext( '2d' ),
foregroundCanvas = document.createElement( 'canvas' ),
foregroundContext = foregroundCanvas.getContext( '2d' ),
// Whichever context is currently being edited.
context = displayContext,
measureCache = {};
/**
* @return {number} width in pixels
*/
function measure( text ) {
var font = context.font,
cache = measureCache[ font ] || ( measureCache[ font ] = {} ),
cachedLength = cache[ text ];
if ( cachedLength ) {
return cachedLength;
} else {
return cache[ text ] = context.measureText( text ).width;
}
}
// TODO: Cache the slice results somewhere.
// Should only calculate once per text/size/width combo.
/**
* Draw text within a certain width, shrinking up to 15% and clipping with
* an ellipse if necessary.
* @param {string} text The text to draw
* @param {number} maxWidth Maximum allowed width
* @param {number} X
* @param {number} Y
*/
function drawClippedText( text, maxWidth, X, Y ) {
var length = text.length,
textWidth = measure( text ),
// ellipse = '…',
ellipse = '...',
ellipsisWidth,
maxShrink = 0.85;
if ( textWidth * maxShrink <= maxWidth ) {
context.fillText( text, X, Y, maxWidth );
} else if ( text.length > 1 ) {
ellipsisWidth = measure( ellipse );
// If the ellipse alone is too large, there's nothing we can do.
if ( ellipsisWidth >= maxWidth ) {
return;
}
// Slice the text just enough so that it fits.
var slicedText = text.substr( 0, ( function t( min, max ) {
// Binary search
if ( max - min < 2 ) {
return min;
}
var testLength = ( max + min ) >> 1,
textWidth = measure( text.substr( 0, testLength ) ),
tooBig = ( textWidth + ellipsisWidth ) * maxShrink > maxWidth;
return tooBig ? t( min, testLength ) : t( testLength, max );
} )( 0, length ) );
if ( slicedText ) {
// Use fillText's built-in scaling.
context.fillText( slicedText + ellipse, X, Y, maxWidth );
}
}
}
/**
* Show "Loading..." text on canvas.
*/
function showLoading() {
// canvas.width = canvas.width;
foregroundContext.save();
foregroundContext.fillStyle = 'rgba( 255, 255, 255, 0.5 )';
foregroundContext.fillRect( 0, 0, fullWidth, fullHeight );
foregroundContext.textAlign = 'center';
foregroundContext.baseLine = 'middle';
foregroundContext.font = '30px sans-serif';
foregroundContext.fillStyle = 'black';
foregroundContext.fillText( mw.msg( 'HV-Loading' ), fullWidth / 2, fullHeight / 2 );
foregroundContext.restore();
displayContext.drawImage( foregroundCanvas, 0, 0 );
}
/**
* Paint a line of an edit.
* @param {Object} change
* @param {Number} Y Y-position of the change's row.
* @param {Boolean} highlight Whether this change should be shown in a
* bolder coloring.
*/
function paintChange( change, Y, highlight ) {
var col = change.col,
changeCol = changeCols[ change.col ],
X = changeCol.X,
width = changeCol.width,
skipped = 0;
// I'm uncertain whether 'mod's should have deletions and insertions vertically or horizontally separate.
// Idea: Reverts could be denoted by a fading gradient to the right.
// TODO: Add revert chain, alternating.
( change.revert ? [ 'del', 'add' ] : [ 'add', 'del' ] ).forEach( t => {
if ( change[ t ] ) {
var height = change[ t ] + 1,
colors = highlight ? { del: '#ffcf4d', add: '#57aeff' } : { del: '#ffe49c', add: '#a3d3ff' };
if ( change.revert || change.reverted ) {
// Show reverts as gradients.
// Unsure whether this is a good way to do things. Maybe have an icon instead?
// File:Echo revert icon.svg is a revert icon.
let gradientStartX = change.reverted ? X : changeCols[ col - 1 ].X,
gradientWidth = width + changeCols[ change.reverted ? col + 1 : col - 1 ].width,
gradient = context.createLinearGradient( gradientStartX, 0, gradientStartX + gradientWidth, 0 ),
startType = change.reverted ? t : t === 'add' ? 'del' : 'add';
gradient.addColorStop( 0, colors[ startType ] );
gradient.addColorStop( 1, colors[ startType === 'add' ? 'del' : 'add' ] );
context.fillStyle = gradient;
context.fillRect( X, Y + skipped, width, height );
} else {
context.fillStyle = colors[ t ];
context.fillRect( X, Y + skipped, width, height );
}
skipped = height;
}
} );
// Yeah, I don't like this. Stick with the gradients, maybe.
// if ( change.revert && !changeCol.revert ) {
// let startX = X - 5,
// yPos = Y + ( change.add + change.del ) / 2,
// arrowEnd = changeCol.X + changeCol.width / 2,
// arrowHeadSize = Math.min( 3, arrowEnd - startX / 2 );
// context.strokeStyle = 'purple';
// // Considering...
// context.lineWidth = 1;
// context.beginPath();
// context.moveTo( startX, yPos );
// context.lineTo( arrowEnd, yPos );
// context.moveTo( startX + arrowHeadSize, yPos - arrowHeadSize );
// context.lineTo( startX, yPos );
// context.lineTo( startX + arrowHeadSize, yPos + arrowHeadSize );
// context.stroke();
// }
}
/**
* Paint a vertical arrow representing a paragraph move.
*/
function paintParagraphMove( changeCol, movedParagraph, highlight ) {
var generalMaxArrowWidth = 10,
{ fromY, toY, maxArrowWidth } = movedParagraph,
pointDown = toY > fromY,
top = pointDown ? fromY : toY,
bottom = pointDown ? toY : fromY,
X = changeCol.X + movedParagraph.X,
arrowEdgeWidth = Math.min( maxArrowWidth, bottom - top, generalMaxArrowWidth ) / 2,
arrowHeadDirection = pointDown ? -1 : 1;
if ( arrowEdgeWidth > 1 ) {
context.strokeStyle = highlight ? 'black' : '#555555';
context.beginPath();
context.moveTo( X, top );
context.lineTo( X, bottom );
context.moveTo( X - arrowEdgeWidth, toY + arrowEdgeWidth * arrowHeadDirection );
context.lineTo( X, toY );
context.lineTo( X + arrowEdgeWidth, toY + arrowEdgeWidth * arrowHeadDirection );
context.stroke();
}
}
/**
* @param {String} [highlight] "FOCUS" for bolded lines, "SIMPLE" for basic black.
*/
function paintColumnOutline( changeCol, highlight, isRightEdge ) {
// Rounding is necessary to deal with floating point errors.
var isLastCol = Math.round( changeCol.X + changeCol.width ) === Math.round( fullWidth ),
outlineHeight = ( changeCol.showUser || highlight ) ? barsHeight : barsHeight - userNameHeight;
// Show lines between changes, darker around focused change.
context.fillStyle = highlight ? '#000000' : changeCol.barColor;
context.fillRect( changeCol.X || 0, 0, 1, outlineHeight );
// Add bar at the end.
if ( isLastCol && ( !highlight || !isRightEdge ) ) {
context.fillRect( fullWidth - 1, 0, 1, barsHeight );
}
if ( highlight === 'FOCUS' ) {
context.fillStyle = 'rgba( 0, 0, 0, 0.5 )';
context.fillRect( changeCol.X + ( isRightEdge ? 1 : -1 ), 0, 1, outlineHeight );
}
}
function paintUsername( changeCol, highlight ) {
var { user, userhidden } = changeCol,
displayUser = userhidden ? mw.msg( 'HV-DeletedUser' ) : user,
minFontSize = 10,
maxFontSize = 14,
maxXOffset = 4,
minXOffset = 1,
// The next column with a different username. ALl the space before that
// column is space we can use to fit the username.
rightBoundaryCol =
changeCols.slice( changeCols.indexOf( changeCol ) ).find( compare => {
return !compare.hidden && compare.user !== user;
} ),
// Width available for printing the username.
availWidth = ( rightBoundaryCol ? rightBoundaryCol.X : fullWidth ) - changeCol.X,
// Clamp these two values between min and max, and split the extra between the two.
//
// Pixels between the bar and the username. (Try to have the same amount
// of buffer also available before the next line.)
xOffset = Math.max( minXOffset, Math.min( maxXOffset, minXOffset + ( availWidth - minXOffset * 2 - minFontSize ) / 4 ) ),
// Displayed font size of the username.
fontSize = Math.max( minFontSize, Math.min( maxFontSize, minFontSize + ( availWidth - minXOffset * 2 - minFontSize ) / 2 ) );
context.save();
context.fillStyle = 'black';
context.textAlign = 'end';
context.shadowBlur = highlight ? 0.05 : 0;
context.shadowColor = 'black';
context.translate( changeCol.X + xOffset, barsHeight );
// Write it vertically.
context.rotate( Math.PI / 2 );
// Show own username in different color.
context.fillStyle = user === mw.config.get( 'wgUserName' ) ? '#000066' : '#000000';
context.font = fontSize + 'px sans-serif';
// console.log( user, availWidth, xOffset, fontSize, xOffset * 2 + fontSize );
// context.font = ( highlight ? 'bold' : 'normal' ) + ' 14px sans-serif';
// context.fillStyle = highlight ? '#333333' : 'black';
drawClippedText( displayUser, userNameHeight, 0, 0 );
context.restore();
}
function paintRevertArrow( changeCol ) {
// Backward-pointing arrows represent reverts.
let startX = changeCols[ changeCol.revertTo ].X + changeCols[ changeCol.revertTo ].width / 4,
yPos = barsHeight / 2,
arrowEnd = changeCol.X + changeCol.width / 2,
radius = Math.min( 10, arrowEnd - startX ),
arrowHeadSize = Math.min( 5, arrowEnd - startX - radius / 2 );
if ( startX !== arrowEnd ) {
context.strokeStyle = 'purple';
// Considering...
// context.lineWidth = 2;
context.beginPath();
context.moveTo( startX, yPos );
context.lineTo( arrowEnd - radius, yPos );
context.arc( arrowEnd - radius + 1, yPos + radius, radius, Math.PI * 1.5, Math.PI * 2.1 );
context.moveTo( startX + arrowHeadSize, yPos - arrowHeadSize );
context.lineTo( startX, yPos );
context.lineTo( startX + arrowHeadSize, yPos + arrowHeadSize );
context.stroke();
}
}
function fitDate( changeCol, overrideFollowingDates ) {
// How to handle?
// | 2017, Feb 1
// | 2017, Jan
// 2017, Feb 2
// Does this need a way to walk back to previous dates, giving more space, after areas where there's no room?
// Consider:
// | 2016
// | 2017
// | 2018
// (Current behaviour is 2016, I think.)
// TODO: Document all this.
/**
* Measure how much room there is before a date that doesn't match compareFn.
*
* @param {Function} compareFn
* @return {Number} Number of pixels available.
*/
function getMatchesWidth( compareFn ) {
for ( var i = index, width = 0; i < changeCols.length && ( compareFn( changeCols[ i ].date ) ); i++ ) {
width += changeCol.width;
}
return width;
}
var date = changeCol.date,
index = changeCols.indexOf( changeCol ),
prevDate = ( changeCols.slice( 0, index ).reverse().find( changeCol => changeCol.date.cache && changeCol.date.cache.isVisible ) || {} ).date,
allUnits = [ 'day', 'month', 'year' ],
cache = {};
allUnits.some( ( unit, i ) => {
var largerUnits = allUnits.slice( i ),
// Units that are different than the previous date.
relevantUnits = largerUnits.filter( ( unit, ii ) => !prevDate || largerUnits.slice( ii ).some( unit => prevDate[ unit ] !== date[ unit ] ) );
if ( relevantUnits.length === 0 ) {
return true;
}
//
var bufferSpace = 5,
idealAvailWidth = getMatchesWidth( nextDate => largerUnits.every( compareUnit => nextDate[ compareUnit ] === date[ compareUnit ] ) ) - bufferSpace,
// Don't go into this for optional things like showing month name before date, but...
// Also don't use this when squishing can be used to avoid it.
actualAvailWidth = getMatchesWidth( nextDate => largerUnits.slice( 1 ).every( compareUnit => nextDate[ compareUnit ] === date[ compareUnit ] ) ) - bufferSpace;
// TODO: Consider extending bar different amounts, depending on unit shown.
// ALso consider changing colors. Maybe gradients or something. Should be a way
// to find year markers.
cache.showBar = true;
return [ idealAvailWidth, actualAvailWidth ].some( availWidth => {
// For days in a month, prefer to show the month name even if same as previous change.
return ( relevantUnits.length === 1 && unit === 'day' ? [ [ 'day', 'month' ], [ 'day' ] ] : [ relevantUnits ] ).some( relevantUnits => {
// <year> ', ' <month> ' ' <day>
var text = relevantUnits.reduceRight( ( acc, unit ) => acc + ( acc && { month: ', ', day: ' ' }[ unit ] ) + date[ unit ], '' ),
textWidth = measure( text );
// Check if the text fits. Squash the text as far as 85%, if necessary.
if ( textWidth * 0.85 <= availWidth || overrideFollowingDates ) {
Object.assign( cache, {
text,
availWidth,
textWidth,
isVisible: true,
width: Math.min( textWidth, availWidth ) + bufferSpace
} );
return true;
}
} );
} );
} );
return cache;
}
function fitDates() {
// TODO: Much of this should be in preparation for presentation layer, in
// processDiffs. Should be moved there, maybe add "dateText" to each colGroup.
var lastDateX = 0;
changeCols.forEach( changeCol => {
var date = changeCol.date;
context.font = '12px sans-serif';
// RULES:
// For start:
// Y M D > Y M > Y
// If followed by same day, allow pushing into it.
// If followed by same month, push in with only Y M if necessary.
// Don't squish too much. Prioritize greater units.
// If smooshed by lastDateX, do nothing.
// M D is prefered to D even if same M as previous.
if ( date.cache === undefined ) {
date.cache = {};
if ( lastDateX > date.X ) {
// If this space has already been written on, don't overwrite.
return;
}
Object.assign( date.cache, fitDate( changeCol ) );
if ( date.cache.isVisible ) {
lastDateX = date.X + date.cache.width;
}
}
} );
}
function paintDate( changeCol, highlight = false ) {
var date = changeCol.date,
cache = date.cache,
alreadyVisible = cache.isVisible;
context.save();
context.font = '12px sans-serif';
// TODO: Clean up.
if ( !alreadyVisible && highlight ) {
cache = fitDate( changeCol, true );
// Use a fading transparent-to-white-to-transparent gradient behind
// the highlighted date, to blend with the surrounding dates.
var blendArea = 10,
gradient = context.createLinearGradient(
date.X + 3 - blendArea, 0,
date.X + 3 + cache.textWidth + blendArea, 0
);
gradient.addColorStop( 0, 'rgba( 255, 255, 255, 0 )' );
gradient.addColorStop( 0.1, 'rgba( 255, 255, 255, 1 )' );
gradient.addColorStop( 0.9, 'rgba( 255, 255, 255, 1 )' );
gradient.addColorStop( 1, 'rgba( 255, 255, 255, 0 )' );
context.fillStyle = gradient;
context.fillRect( date.X + 3 - blendArea, barsHeight, cache.textWidth + blendArea * 2, 30 );
}
// Display text
if ( alreadyVisible || highlight ) {
context.fillStyle = 'black';
context.shadowBlur = highlight ? 0.05 : 0;
context.shadowColor = 'black';
context.fillText( cache.text, date.X + 3, barsHeight + 25, alreadyVisible ? cache.availWidth : cache.textWidth );
}
context.restore();
}
/*
// Some ideas for displaying edit summaries somewhere. (Not implemented.)
function parseComment( changeCol ) {
// TODO: Clean up. And move somewhere else.
if ( changeCol.parsedcomment ) {
var dF = document.createElement( 'span' ),
aC;
dF.innerHTML = changeCol.parsedcomment;
aC = dF.querySelector( '.autocomment' );
if ( aC ) {
aC.parentNode.removeChild( aC );
dF.removeChild( dF.firstChild );
}
changeCol.textComment = dF.innerText && dF.innerText.replace( String.fromCharCode( 8206 ), '' ).trim();
}
}
function paintComment( changeCol, lastComment, nextCommentCol, index ) {
if ( changeCol.textComment ) {
var comment = changeCol.textComment,
nextChangeCol = changeCols[ index + 1 ],
upper = index % 2 === 0,
Y = fullHeight - ( upper ? 10 : 0 ) - 1,
buffer = 3;
if ( measure( comment ) < changeCol.width || true ) {
// Probably move above dates.
// Also maybe increase the font size. Certainly at least set it.
// Not at all sure that including comments on-canvas is a good idea.
context.fontStyle = '10px sans-serif';
context.fillStyle = changeCol.barColor;
context.fillRect( changeCol.X, Y - 10 - ( upper ? 10 : 0 ), 1, 10 + ( upper ? 10 : 0 ) );
context.fillStyle = 'black';
// context.fillText( comment, changeCol.X, fullHeight - 10 );
drawClippedText( comment,
( nextCommentCol ? nextCommentCol.X : fullWidth ) - changeCol.X - buffer * 2,
changeCol.X + buffer,
Y
);
}
}
}
// */
function paintIcon( X, Y, [ iconWidth, iconHeight, iconSvgCode ], iconScale ) {
context.save();
context.translate( X - iconWidth * iconScale / 2, Y - iconHeight * iconScale / 2 );
context.scale( iconScale, iconScale );
context.fill( new Path2D( iconSvgCode ) );
context.restore();
}
function paintLog( log, highlight ) {
var { X, Y, expiryX, color } = log;
if ( expiryX ) {
// Paint protected area background.
context.save();
context.globalAlpha = 0.11;
context.fillStyle = color; //'rgba( 0, 255, 0, 0.06 )';
// context.fillStyle = color + '1C'; //'rgba( 0, 255, 0, 0.06 )';
context.fillRect( X, 0, expiryX, barsHeight );
context.restore();
}
// Paint the vertical line.
context.fillStyle = highlight ? 'red' : color;
context.fillRect( X, 0, 1, barsHeight );
paintIcon( X, Y, icons[ log.iconType ], 0.03 );
// context.strokeRect( X - iconWidth * iconScale / 2, Y - iconHeight * iconScale / 2, iconWidth * iconScale, iconHeight * iconScale );
// context.strokeRect( 8, 0, 16, barsHeight )
// context.strokeRect( 8, barsHeight / 2, 500, 1 )
}
function paintHeaders() {
// Record the Y position of the lowest header so far in each column, to
// avoid overlap.
var lastHeaderInColumn = [],
minHeaderHeight = 9,
maxHeaderWidth = 300;
context.fillStyle = 'black';
context.font = '9px sans-serif';
context.textBaseline = 'bottom';
changeRows.forEach( changeRow => {
var Y = changeRow.Y;
changeRow.headers && changeRow.headers.forEach( header => {
var { text: headerText, start, end } = header,
textWidth = Math.min( measure( headerText ), maxHeaderWidth ),
// firstAvailStart = changeCols.slice( _start ).findIndex( ( changeCol, i ) => {
// var lastHeader = lastHeaderInColumn[ _start + i ] || 0;
// return !changeCol.hidden && lastHeader < Y - minHeaderHeight;
// } ),
// start = firstAvailStart !== -1 ? _start + firstAvailStart : _start,
xStart = changeCols[ start ].X,
xEnd = ( changeCols[ end ] || { X: fullWidth } ).X,
lastInColumn = lastHeaderInColumn[ start ] || 0,
blockingColumn = lastInColumn > Y - minHeaderHeight ?
// Blocked by space constraints from even starting.
0 :
//
changeCols
.slice( start )
.findIndex( ( changeCol, i ) => {
var col = start + i,
lastHeader = lastHeaderInColumn[ col ] || 0,
isBlocked = lastHeader > Y - minHeaderHeight,
outOfLength = xStart + textWidth < changeCol.X,
headerDeleted = end === col;
return isBlocked || headerDeleted || outOfLength;
} ),
interruptX = blockingColumn === -1 ? fullWidth : changeCols[ start + blockingColumn ].X;
context.save();
if ( blockingColumn !== 0 ) {
for ( let i = start; i < start + blockingColumn; i++ ) {
lastHeaderInColumn[ i ] = Y;
}
// Draw the text, with a translucent white background and shadow.
context.fillStyle = 'rgba( 255, 255, 255, 0.3 )';
// context.fillStyle = 'blue';
context.fillRect( xStart, Y - minHeaderHeight, Math.min( interruptX - xStart, textWidth ), minHeaderHeight );
context.shadowBlur = 1;
context.shadowColor = 'white';
context.fillStyle = 'black';
drawClippedText( headerText, interruptX - xStart, xStart, Y );
}
// To consider: When hovering over a line or change to the line, show
// the header even if blocked by other headers.
// Underline
// The line should either be translucent or the colors should go on top of it.
// Test article: "Knuckles' Chaotix": TarkusAB's change is not at all visible, hidden behind the line I think.
// TODO: Consider forcing a 1px minimum for header lines, even if no changes in them.
context.fillStyle = 'rgba( 170, 170, 170, 0.4 )';
context.fillRect( xStart, Y, xEnd - xStart, 1 );
context.restore();
} );
} );
}
function paintBackground() {
backgroundCanvas.width = backgroundCanvas.width;
context = backgroundContext;
// Display changes
changeRows.forEach( changeRow => {
var Y = changeRow.Y;
changeRow.changes.forEach( change => {
paintChange( change, Y );
} );
} );
// Display user names, dates, dividers between columns.
context.font = '14px sans-serif';
changeCols.forEach( changeCol => {
if ( changeCol.hidden ) {
return;
}
// USERNAMES
if ( changeCol.showUser ) {
paintUsername( changeCol, false );
}
// Show lines between changes, darker around focused change.
paintColumnOutline( changeCol, false );
} );
// Display dates below changes
fitDates();
changeCols.forEach( changeCol => {
var date = changeCol.date;
if ( date && date.cache !== undefined ) {
// Extend the divider line to reach down to the date.
if ( date.cache.showBar ) {
context.fillStyle = date.barColor;
context.fillRect( date.X, barsHeight, 1, 20 );
}
// Display the date.
// TODO: Consider bolding the currently highlighted date, or something.
paintDate( changeCol );
}
} );
// Show protection log.
// TODO: Should the highlights be behind the diffs themselves?
logIcons.forEach( log => {
paintLog( log, false );
} );
}
function paintForeground() {
foregroundCanvas.width = foregroundCanvas.width;
context = foregroundContext;
// Show headers
paintHeaders();
// Draw revert arrows, X icons for deleted revisions.
changeCols.forEach( changeCol => {
if ( changeCol.revertTo !== undefined ) {
paintRevertArrow( changeCol );
// Should there be mini-arrows for partial reverts, or reverts of a single
// row several edits later? Seems problematic to leave them out...
//
// Should only be when no big arrow is also present.
// Maybe smaller stroke width?
}
if ( changeCol.missingcontent ) {
context.fillStyle = '#666666';
paintIcon( changeCol.X + changeCol.width / 2, barsHeight / 2, icons.revdeleted, 0.02 );
}
} );
/*
var lastComment;
changeCols.forEach( parseComment );
changeCols.filter( changeCol => changeCol.textComment ).forEach( ( changeCol, i, allCommentedCols ) => {
// Maybe show full comment when highlighted, with white background?
// Would mean moving this to paintBackground and adding bit to paint.
lastComment = paintComment( changeCol, lastComment, allCommentedCols[ i + 2 ], i % 2 );
} );
// */
}
/**
* Draw the changes on the canvas.
*
* @param {Object} [focusConfig]
* @param {Object} focusConfig.changeCol A specific column/edit to highlight.
* @param {Array} focusConfig.changes A set of changes within the column
* to be highlighted.
* @param {Object} focusConfig.extraHighlight
*/
function paint( { changeCol: focusChangeCol, changes: focusChanges = [], locked: extraHighlight } = {} ) {
// if ( !changeCols ) {
// // Not yet loaded.
// return;
// }
var focusChange = focusChanges[ 0 ],
focusAll = !focusChangeCol && focusChanges.length === 0;
// Reset - blank the canvas.
canvas.width = canvas.width;
context = displayContext;
// The "background" has everything that doesn't need to be updated
// frequently, but can also be behind the "active" parts.
displayContext.drawImage( backgroundCanvas, 0, 0 );
// Display changes
changeRows.forEach( changeRow => {
changeRow.changes.forEach( change => {
// The non-focused changes are already displayed via the backgroundCanvas.
// Here we're just repainting the focused changes over them.
if ( focusAll || focusChanges.includes( change ) ) {
paintChange( change, changeRow.Y, focusAll || focusChanges.includes( change ) );
}
} );
} );
// Display user names, dates, dividers between columns.
context.font = '14px sans-serif';
var prevCol;
changeCols.forEach( ( changeCol, i ) => {
if ( changeCol.hidden ) {
return;
}
if ( focusChangeCol ) {
// USERNAMES
if ( changeCol.showUser && focusChangeCol.user === changeCol.user ) {
// Bold any username matching the author of the highlighted edit.
paintUsername( changeCol, true );
}
// Show lines between changes, darker around focused change.
if ( [ changeCol, prevCol ].includes( focusChangeCol ) ) {
paintColumnOutline( changeCol, extraHighlight ? 'FOCUS' : 'SIMPLE', prevCol === focusChangeCol );
}
}
// Draw vertical arrows representing paragraph moves, darker when focused.
changeCol.movedParagraphs.forEach( movedParagraph => {
paintParagraphMove( changeCol, movedParagraph,
focusChanges.some( focusChange => [ ...movedParagraph.to, ...movedParagraph.from ].includes( focusChange ) )
);
} );
prevCol = changeCol;
} );
// Highlight the date of the focused edit.
focusChangeCol && ( () => {
var dateMatch = changeCols.find( changeCol => {
return changeCol.date && changeCol.date.cache && changeCol.date.cache.isVisible && [ 'year', 'month', 'day' ].every( unit => {
return changeCol.date[ unit ] === focusChangeCol.date[ unit ];
} );
} );
if ( dateMatch ) {
// Date is already visible.
paintDate( dateMatch, true );
} else if ( changeCols.includes( focusChangeCol ) ) {
// Date is not currently visible. Insert.
paintDate( focusChangeCol, true );
}
} )();
// Show focused logs highlighted in red.
// TODO: Should the highlights be behind the diffs themselves?
logIcons.forEach( log => {
if ( focusChange === log ) {
paintLog( log, true );
}
} );
// Things that don't need updating, and are in front of the other stuff:
// headers, revert arrows.
displayContext.drawImage( foregroundCanvas, 0, 0 );
}
// Should the left/right edges of this box be gray?
function outlineRows( group1, group2 ) {
displayContext.strokeStyle = 'black';
displayContext.strokeRect( 0, group1.Y, fullWidth, group2.Y + group2.height - group1.Y );
}
function outlineCols( group1, group2 ) {
displayContext.strokeStyle = 'black';
displayContext.strokeRect( group1.X, 0, group2.X + group2.width - group1.X, fullHeight );
}
function init() {
// This, along with a reset of fullWidth and formatDiffs and part of
// processLogs, should be redone on every window resize. TODO.
backgroundCanvas.width = foregroundCanvas.width = fullWidth;
backgroundCanvas.height = foregroundCanvas.height = fullHeight;
}
return {
paint,
showLoading,
outlineRows,
outlineCols,
// NOTE: If necessary, this could set a local version of changeCols/changeRows/logs.
newData() {
paintBackground();
paintForeground();
paint();
},
init
};
} )();
domHandler = ( () => {
var container = document.createElement( 'div' ),
// (Only used in displayDiff.)
// Holds a summary of the highlighted edit, including author, edit summary, timestamp, etc.
summary = document.createElement( 'div' ),
// Holds the diff table itself, below the summary.
// (Currently exposed by domHandler, to attach event handler and scroll
// position. TODO: Fix.)
diffHolder = document.createElement( 'div' ),
// Navigation buttons. (Constructed later by createButtons().)
buttons = {},
contentText = document.querySelector( '#mw-content-text' ),
contentSub = document.querySelector( '#contentSub' ),
// Stores the default display, in case we need to put it back if the user
// disables HistoryView.
normalHistoryFrag = document.createDocumentFragment(),
initializedDomHandler = false,
// Set to true after init() is called.
initializedDisplay = false;
container.appendChild( canvas );
container.appendChild( summary );
container.appendChild( diffHolder );
// TODO: Only update DOM if there's been an actual change.
/**
* Display the HTML of a diff, and its associated author info and summary.
*
* @param {Object} [diff] Either a changeCol (for an edit) or a log, to be
* displayed. (If omitted, just blank the area.)
*/
function displayDiff( diff ) {
function createInfoSpan() {
// TODO: Redlinks
function addLink( text, page, title ) {
var link = diffInfoSpan.appendChild( document.createElement( 'a' ) );
link.innerText = text;
page && ( link.href = mw.config.get( 'wgArticlePath' ).replace( /\$1/, page ) );
title && ( link.title = title );
return link;
}
let title = new mw.Title( mw.config.get( 'wgPageName' ) ),
{ anon, user, userhidden, timestamp, parsedcomment, revid, priorrevid, isLog } = diff,
formattedTimestamp = timestamp && formatTimestamp( timestamp ),
diffInfoSpan = document.createElement( 'span' );
// Show user name, talk link, contribs
if ( user ) {
if ( !userhidden ) {
addLink( user, ( anon ? 'Special:Contributions/' : mw.config.get( 'wgFormattedNamespaces' )[ 2 ] + ':' ) + user );
diffInfoSpan.appendChild( document.createTextNode( ' (' ) );
addLink( mw.msg( 'talkpagelinktext' ), mw.config.get( 'wgFormattedNamespaces' )[ 3 ] + ':' + user );
if ( !anon ) {
diffInfoSpan.appendChild( document.createTextNode( ' | ' ) );
addLink( mw.msg( 'contribslink' ), 'Special:Contributions/' + user );
}
diffInfoSpan.appendChild( document.createTextNode( ') ' ) );
} else {
let delUser = diffInfoSpan.appendChild( document.createElement( 'span' ) );
delUser.className = 'history-deleted';
delUser.appendChild( document.createTextNode( mw.msg( 'HV-RemovedUser' ) ) );
diffInfoSpan.appendChild( document.createTextNode( ' ' ) );
}
}
// Flags
[ 'minor', 'bot' ].forEach( type => {
if ( diff[ type ] ) {
var abbr = diffInfoSpan.appendChild( document.createElement( 'abbr' ) );
abbr.innerText = mw.msg( type + 'editletter' );
abbr.className = type + 'edit';
abbr.title = mw.msg( 'recentchanges-label-' + type );
}
} );
if ( isLog ) {
if ( diff.type === 'lastSeen' ) {
diffInfoSpan.appendChild( document.createTextNode( mw.msg( 'HV-LastVisited', formatTimestamp( diff.timestamp ) ) ) );
}
}
if ( diff.isMultipleRevisions ) {
diffInfoSpan.appendChild( document.createTextNode( mw.msg( 'HV-MultiRev' ) ) );
}
// Edit summary
if ( parsedcomment ) {
let summarySpan = diffInfoSpan.appendChild( document.createElement( 'span' ) );
summarySpan.className = 'comment';
summarySpan.innerHTML = ' (' + parsedcomment + ') ';
}
// Timestamp
if ( formattedTimestamp && diff.type !== 'lastSeen' ) {
if ( revid ) {
let dateElem = diffInfoSpan.appendChild( document.createElement( 'span' ) ),
dateLink;
if ( diff.missingcontent ) {
dateElem.className = 'history-deleted';
dateElem.appendChild( document.createTextNode( formattedTimestamp ) );
} else {
dateLink = dateElem.appendChild( document.createElement( 'a' ) );
dateLink.appendChild( document.createTextNode( formattedTimestamp ) );
dateLink.href = title.getUrl( { oldid: revid } );
}
} else {
diffInfoSpan.appendChild( document.createTextNode( formattedTimestamp ) );
}
}
// Undo/thank buttons.
if ( !isLog && !diff.missingcontent ) {
diffInfoSpan.appendChild( document.createTextNode( ' (' ) );
addLink( mw.msg( 'editundo' ), null, mw.msg( 'tooltip-undo' ) ).href = title.getUrl( { action: 'edit', undoafter: priorrevid, undo: revid } );
if ( diff.user ) {
diffInfoSpan.appendChild( document.createTextNode( ' | ' ) );
addLink( mw.msg( 'thanks-thank' ), 'Special:Thanks/' + revid, mw.msg( 'thanks-thank-tooltip' ) );
}
diffInfoSpan.appendChild( document.createTextNode( ')' ) );
}
return diffInfoSpan;
}
// Clear diff table
for ( ; diffHolder.firstChild; ) {
diffHolder.removeChild( diffHolder.firstChild );
}
// ...and summary block
if ( summary.firstChild ) {
summary.removeChild( summary.firstChild );
}
if ( diff ) {
let { elem, delLogElem } = diff;
diffHolder.appendChild( elem );
delLogElem && diffHolder.appendChild( delLogElem );
if ( diff.cachedInfoElem ) {
summary.appendChild( diff.cachedInfoElem );
} else {
diff.cachedInfoElem = summary.appendChild( createInfoSpan() );
}
}
}
/**
* If the change is not already visible, scroll to it.
*/
function scrollChangeIntoView( change ) {
var inlineChanges,
minInlineChangeOffset;
if ( change ) {
if ( change.type === 'mod' ) {
// We want to scroll to the earliest inline change, if it's not
// already visible.
if ( change.minInlineChangeOffset !== undefined ) {
// Cached offset.
minInlineChangeOffset = change.minInlineChangeOffset;
} else {
inlineChanges = change.elem.querySelectorAll( '.diffchange-inline' );
if ( inlineChanges.length ) {
// inlineChangeOffsets = [ ...change.elem.querySelectorAll( '.diffchange-inline:first-of-type' ) ].map( x => x.offsetTop );
minInlineChangeOffset = Math.min( ...[ ...inlineChanges ].map( x => x.offsetTop ) );
change.minInlineChangeOffset = minInlineChangeOffset;
}
}
}
if ( minInlineChangeOffset && minInlineChangeOffset > diffHolder.offsetHeight ) {
diffHolder.scrollTop = change.elem.offsetTop + minInlineChangeOffset;
} else {
diffHolder.scrollTop = change.elem.offsetTop;
}
}
}
/**
*
*/
function setUpDisplay() {
initializedDisplay = true;
fullWidth = contentText.offsetWidth;
// HTML/CSS
canvas.height = fullHeight;
canvas.width = fullWidth;
diffHolder.style.height = spaceHeight +'px';
diffHolder.style.overflow = 'auto';
diffHolder.style.clear = 'both';
contentText.insertAdjacentElement( 'afterbegin', container );
// Remove normal history page.
for ( ; container.nextSibling; ) {
normalHistoryFrag.appendChild( container.nextSibling );
}
// Hide "View logs for this page".
contentSub.style.display = 'none';
}
function shutDownDisplay() {
container.parentNode.replaceChild( normalHistoryFrag, container );
contentSub.style.display = 'block';
}
/**
* Create the pan and zoom buttons and such, and attach click handlers.
*/
function createButtons() {
// TODO: Maybe also buttons for moving by one change?
// Create buttons.
[
[ 'start', '|<-', 'first', mw.msg( 'HV-ShowEarliest' ) ], // TODO.
[ 'prev', '<-', 'previous', mw.msg( 'HV-ShowEarlier' ) ],
// Eh, maybe not.
// Maybe a button separate from the group, w/ text?
// [ 'zoomout', 'a', 'exitFullscreen' ],
[ 'next', '->', 'next', mw.msg( 'HV-ShowLater' ) ],
[ 'end', '->|', 'last', mw.msg( 'HV-ShowLatest' ) ]
].forEach( ( [ key, label, icon, title ] ) => {
buttons[ key ] = new OO.ui.ButtonWidget( {
icon,
title
} );
} );
[
[ 'zoomin', '(+)', 'add', mw.msg( 'HV-ZoomIn' ), '' ],
[ 'zoomout', '(-)', 'subtract', mw.msg( 'HV-ZoomOut' ), '' ]
].forEach( ( [ key, label, icon, title ] ) => {
buttons[ key ] = new OO.ui.ButtonWidget( {
// This should ideally use magnifying +/- icons, but there aren't any in ooui.
// File:VisualEditor - Icon - Zoom+.svg
// There's also +/- for zoom in/out...
icon,
title,
// label: title
} );
} );
buttons.rowFilter = new OO.ui.ButtonWidget( {
label: 'Showing specific rows',
indicator: 'clear',
classes: [ 'yr-historyview-rowFilterButton' ],
} );
// Not strictly a button, but goes in the button area.
buttons.posText = new OO.ui.Element( {
text: apiHandler.getPosition(),
classes: [ 'yr-historyview-posText' ]
} );
toggleRowFilterButton( false );
// Insert buttons.
[
[
buttons.start, buttons.prev,
buttons.posText,
buttons.rowFilter,
buttons.next, buttons.end
],
[
buttons.zoomin, buttons.zoomout
]
].forEach( buttonsInGroup => {
container.insertBefore( new OO.ui.ButtonGroupWidget( {
items: buttonsInGroup,
classes: [ 'yr-historyview-buttongroup' ]
} ).$element[ 0 ], summary );
} );
updateButtonDisplay();
}
function updateButtonDisplay() {
[ 'start', 'prev', 'next', 'end' ].forEach( ( type, i ) => {
buttons[ type ].setDisabled( apiHandler.canPan( i > 1 ? -1 : 1 ) === false );
} );
[ 'zoomin', 'zoomout' ].forEach( type => {
buttons[ type ].setDisabled( apiHandler.canZoom( type === 'zoomin' ? 1 : -1 ) === false );
} );
buttons.posText.$element.text( apiHandler.getPosition() );
}
function toggleRowFilterButton( show ) {
buttons.rowFilter.$element.toggle( show );
}
// TODO
function showError( err ) {
summary.innerText = 'ERROR: ' + err;
}
function createSettingsMenu() {
var settingsButton,
disableButton,
disableText = mw.msg( 'HV-Disable' ),
enableText = mw.msg( 'HV-Enable' ),
disableTT = mw.msg( 'HV-DisableTT' ),
enableTT = mw.msg( 'HV-EnableTT' ),
indicators = document.querySelector( '.mw-indicators' );
function saveSetting( type, value ) {
settings[ type ] = value;
( new mw.Api() ).saveOption( 'userjs-historyview-settings', JSON.stringify( settings ) );
}
// Temporary, until T262510 is fixed
indicators.style.zIndex = 1;
settingsButton = indicators.appendChild(
new OO.ui.PopupButtonWidget( {
framed: false,
icon: 'advanced',
title: 'History settings',
popup: {
label: 'Settings',
padded: true,
$content:
// The settings menu.
$( '<p>' ).append(
( disableButton = new OO.ui.ButtonWidget( {
framed: false,
label: settings.disabled ? enableText : disableText,
classes: [ 'yr-historyview-settingsbutton' ],
title: disableTT
} ).on( 'click', function () {
// Disable or enable HistoryView.
saveSetting( 'disabled', !settings.disabled );
if ( !settings.disabled ) {
// Enable
if ( initializedDisplay ) {
setUpDisplay();
canvasDisplay.paint();
} else {
init();
}
} else {
// Disable
shutDownDisplay();
}
disableButton.setLabel( settings.disabled ? enableText : disableText );
disableButton.setTitle( settings.disabled ? enableTT : disableTT );
} ) ).$element,
// Link to the logs
new OO.ui.ButtonWidget( {
framed: false,
label: mw.msg( 'HV-ViewLogs' ),
href: new mw.Title( 'Special:Log' ).getUrl( { page: mw.config.get( 'wgPageName' ) } ),
classes: [ 'yr-historyview-settingsbutton' ]
} ).$element,
// Select date. (Not yet available.)
// Actually, maybe this should be done by clicking on the "1 - 59 of ..." area.
new OO.ui.ButtonWidget( {
framed: false,
label: mw.msg( 'HV-SelectDate' ),
classes: [ 'yr-historyview-settingsbutton' ],
title: '(Not yet available.)',
disabled: true // Until implemented
} ).$element,
// Filter by tags. (Use rvtag in prop=revisions. TODO.)
new OO.ui.ButtonWidget( {
framed: false,
label: mw.msg( 'HV-FilterTags' ),
classes: [ 'yr-historyview-settingsbutton' ],
title: '(Not yet available.)',
disabled: true // Until implemented
} ).$element
),
head: true,
width: 250,
}
} ).$element[ 0 ]
);
}
function initDomHandler() {
if ( initializedDomHandler ) {
return false;
}
initializedDomHandler = true;
mw.util.addCSS( `
.yr-historyview-buttongroup {
float: right;
}
.yr-historyview-posText {
display: inline-block;
margin: 0 1em;
}
.yr-historyview-rowFilterButton {
margin-right: 10px;
}
.yr-historyview-settingsbutton {
display: block;
}
`);
createSettingsMenu();
}
return {
init: initDomHandler,
setUpDisplay,
displayDiff,
showError,
createButtons,
buttons,
updateButtonDisplay,
toggleRowFilterButton,
// TODO: Remove when possible.
diffHolder,
scrollChangeIntoView
};
} )();
/**
* Attach event listeners, user interactions.
*/
function buildInteractions() {
var changesInView = {
changeCol: null,
// Changes that are scrolled-to/visible within the diffHolder.
changes: [],
// Last change focused via the canvas.
change: null,
locked: false
},
mouseIsDown = false,
mouseDownStartPosition;
// TODO: Consider deprecating and reworking things.
/**
* Show diff corresponding with the passed coordinates on the canvas, and
* scroll to the row.
* @return {Object} focusChange
*/
function focusPosition( X, Y ) {
var newFocus = findChangesFromPosition( X, Y );
// Only refresh the display if something has changed.
if ( changesInView.change !== newFocus.change || changesInView.changeCol !== newFocus.changeCol ) {
Object.assign( changesInView, newFocus );
showChange( newFocus );
}
return newFocus.change;
}
/**
* Display a change in the diff area, updating the canvas as necessary.
*/
function showChange( { changeCol, change } ) {
if ( change && change.isLog ) {
domHandler.displayDiff( change );
} else {
// An actual edit, not a log.
domHandler.displayDiff( changeCol );
// Scroll to the focused lines.
domHandler.scrollChangeIntoView( change );
}
highlightFocus();
}
/**
* Find change(s) corresponding with the X/Y coordinates on the canvas.
* (Return value assigned to changesInView.)
*
* @return {Object} return.change A row of an edit, or a log, matching the position.
* @return {Object} [return.changeCol]
*/
function findChangesFromPosition( X, Y ) {
var changeCol = changeCols.find( changeCol => changeCol.X <= X && changeCol.X + changeCol.width > X ),
index = changeCol && changeCol.changes[ 0 ] && changeCol.changes[ 0 ].col,
minDistance = 1000,
focusChange,
focusLog = logIcons.find( log => {
return X > log.X - 10 && X < log.X + 10 && Y > log.Y - 10 && Y < log.Y + 10;
} );
if ( focusLog ) {
return { change: focusLog, changes: [ focusLog ], changeCol: null };
}
changeRows.forEach( changeRow => changeRow.changes.forEach( change => {
if ( change.col === index ) {
let top = changeRow.Y - Y,
bottom = changeRow.Y + ( change.add + change.del ) - Y,
distance = ( top < 0 && bottom > 0 ) ?
// Cursor is between the top and bottom of the change.
0 :
// Cursor is outside the change. Check actual distance to closest
// part of the change.
Math.min( Math.abs( top ), Math.abs( bottom ) );
if ( distance < minDistance || !focusChange ) {
minDistance = distance;
focusChange = change;
}
}
} ) );
return { change: focusChange, changes: undefined, changeCol };
}
// This is called once, by highlightFocus.
// TODO: Move some of this to domHandler. Need to move diffHolder refs.
// Maybe move the whole thing to domHandler?
// Also, does this need an argument passed? (changesInView.changeCol)
/**
* Find all changes that have visible (within scroll area) rows.
* @return {Array} inView List of changes in view.
*/
function findChangesInView( changeCol ) {
var inView = [],
scroll = domHandler.diffHolder.scrollTop;
// TODO: Improve performance.
changeCol.changes.forEach( change => {
var { elem } = change,
// Should this be cached? Unsure. (If this is done, need to reset on resize.)
// top = change.offsetTop || ( change.offsetTop = elem.offsetTop ),
top = elem.offsetTop,
bottom = top + elem.offsetHeight;
if ( top < scroll + spaceHeight && bottom > scroll ) {
inView.push( change );
}
} );
return inView;
}
/**
* Highlight the areas of the canvas representing changes that are currently
* visible and within the scrolled-to area of the diffHolder element.
*/
function highlightFocus() {
if ( changesInView.changeCol ) {
changesInView.changes = findChangesInView( changesInView.changeCol );
} else {
// canvasDisplay.paint( { changes: [ change ] } );
}
canvasDisplay.paint( changesInView );
}
function findSelectedAreas( { X: X1, Y: Y1 }, { X: X2, Y: Y2 } ) {
var type = Math.abs( Y1 - Y2 ) > Math.abs( X1 - X2 ) ? 'row' : 'col';
const [ findSelectedRows, findSelectedCols ] =
[ [ changeRows, 'Y', 'height' ], [ changeCols, 'X', 'width' ] ].map( ( [ changeGroups, position, size ] ) => ( d1, d2 ) => {
var first = Math.min( d1, d2 ),
second = Math.max( d1, d2 ),
firstGroup = changeGroups.find( changeGroup => {
return changeGroup && changeGroup[ position ] + changeGroup[ size ] > first;
} ) || changeGroups.find( x => x ),
secondGroup = changeGroups.find( changeGroup => {
return changeGroup && changeGroup[ position ] <= second && changeGroup[ position ] + changeGroup[ size ] > second;
} ) || changeGroups[ changeGroups.length - 1 ];
return [ firstGroup, secondGroup ];
} );
return {
type,
groups: type === 'row' ? findSelectedRows( Y1, Y2 ) : findSelectedCols( X1, X2 )
};
}
function selectAreas( from, to ) {
// Only show selected rows/columns.
var { type, groups: [ group1, group2 ] } = findSelectedAreas( from, to );
if ( type === 'row' ) {
// Filter for specific rows.
// The boundary is a row just outside of the selected area, so that
// newly-inserted rows from not-yet-loaded columns are shown if
// outside the outermost row but not past the row that was previously
// just outside the range.
var filteredChangeRows = changeRows.filter( x => x.changes && x.changes.length ),
group1Outside = filteredChangeRows[ filteredChangeRows.indexOf( group1 ) - 1 ],
group2Outside = filteredChangeRows[ filteredChangeRows.indexOf( group2 ) + 1 ];
// This should save the elems, I think.
// Something needs to store the edges data, for repeated filters.
// (Theoretically, that could be done here. Not recommended.)
// Also needs to be some way to navigate from filtered rows...
// Also something needs to manage the extra requests, skipping blank cols.
// Loop if less than min, unless retrieved more than max (500).
// I'm starting to think that having selectRows attached to data is a bad idea.
apiHandler.selectRows( group1, group2 );
// apiHandler.selectRows( group1, group2, ( [ results, logs ] ) => {
//
// } );
// Okay, notes on this:
// * The filter data block can include whatever stuff I like. It gets passed on.
// * buildInteractions has everything necessary to include local variables
// that can be accessed from all relevant parts except inside apiHandler.
// * Button handlers can be modified in here, including pan.
// * Can include equivalent row numbers at beginning and end? Would that work?
canvasDisplay.showLoading();
showData( {
top: group1Outside && group1Outside.changes[ 0 ].elem,
bottom: group2Outside && group2Outside.changes[ 0 ].elem
} );
domHandler.toggleRowFilterButton( true );
console.log( group1, group2 );
} else {
if ( group1 !== group2 ) {
apiHandler.selectCols( group1.revid, group2.revid ).then( compare => {
// Not sure what to do in the author info field, and such.
// I don't like just showing the last user. Not clear enough.
var { $table: $elem } = getCompareElement( compare );
// changesInView = { locked: true };
changesInView.locked = true;
showData();
domHandler.displayDiff( {
elem: $elem[ 0 ],
revid: group2.revid,
priorrevid: group1.priorrevid,
isMultipleRevisions: true
} );
} );
}
}
console.log( 'SELECTED', group1, group2 );
}
// Add event handlers
canvas.onmousemove = e => {
if ( apiHandler.isBusy() ) {
// Haven't finished loading yet.
return;
}
var { offsetX, offsetY } = e;
if ( !mouseIsDown ) {
if ( !changesInView.locked ) {
focusPosition( offsetX, offsetY );
}
} else {
// Show selection
canvasDisplay.paint();
if ( changeCols.length ) {
var { type, groups: [ group1, group2 ] } = findSelectedAreas( mouseDownStartPosition, { X: offsetX, Y: offsetY } );
if ( type === 'row' ) {
canvasDisplay.outlineRows( group1, group2 );
// context.strokeRect( 0, group1.Y, fullWidth, group2.Y + group2.height - group1.Y );
} else {
canvasDisplay.outlineCols( group1, group2 );
// context.strokeRect( group1.X, 0, group2.X + group2.width - group1.X, fullHeight );
}
}
}
};
// paintColumnOutline
canvas.onclick = e => {
var { offsetX, offsetY } = e,
{ change: focus } = findChangesFromPosition( offsetX, offsetY ),
// This causes an error when only visible change is icon. TODO: Fix.
lockedAlreadyInView = changesInView.locked && changesInView.changes && changesInView.changes.includes( focus );
if ( lockedAlreadyInView ) {
// Unlock
changesInView.locked = false;
} else {
changesInView.locked = true;
focusPosition( offsetX, offsetY );
}
highlightFocus();
};
canvas.onmouseenter = function () {
changesInView.locked = false;
highlightFocus();
};
canvas.onmousedown = function ( e ) {
var { offsetX, offsetY } = e;
mouseIsDown = true;
mouseDownStartPosition = { X: offsetX, Y: offsetY };
};
canvas.onmouseup = function ( e ) {
var { offsetX, offsetY } = e;
if ( !apiHandler.isBusy() && changeCols.length ) {
if ( mouseDownStartPosition.X !== offsetX || mouseDownStartPosition.Y !== offsetY ) {
selectAreas( mouseDownStartPosition, { X: offsetX, Y: offsetY } );
}
}
mouseIsDown = false;
};
// Update highlighted changes to match current scroll position
domHandler.diffHolder.addEventListener( 'scroll', function () {
// Only update when focusing an actual change, not a protect log
if ( changesInView.changeCol ) {
highlightFocus();
}
}, { passive: true } );
[
[ 'prev', () => apiHandler.pan( 1 ) ],
[ 'next', () => apiHandler.pan( -1 ) ],
[ 'start', () => apiHandler.panToEdge( 1 ) ],
[ 'end', () => apiHandler.panToEdge( -1 ) ],
[ 'zoomin', () => apiHandler.zoom( 1 ) ],
[ 'zoomout', () => apiHandler.zoom( -1 ) ],
[ 'rowFilter', () => {
// TODO.
apiHandler.selectRows();
} ]
].forEach( ( [ type, fn ] ) => {
domHandler.buttons[ type ].on( 'click', () => {
canvasDisplay.showLoading();
fn();
showData();
// Should these be after .then?
domHandler.toggleRowFilterButton( false );
domHandler.displayDiff( false );
} );
} );
}
// TODO: Better name.
/**
* @param {Object} [filterRows]
*/
function showData( filterRows ) {
// TODO: Add showLoading here?
var promise = apiHandler.getData()
.then( ( [ diffs, logs ] ) => {
( { changeRows, changeCols } = processDiffs( diffs, filterRows ) );
// TODO: Extra apiHandler action must be taken here if insufficient number of
// diffs after row filtering.
if ( filterRows && changeCols.filter( changeCol => !changeCol.hidden ).length < ( filterRows.cols || ( filterRows.cols = diffs.length ) ) ) {
if ( apiHandler.displayMore() ) {
return showData( filterRows );
}
}
formatDiffs( changeRows, changeCols );
logIcons = processLogs( logs );
domHandler.updateButtonDisplay();
canvasDisplay.newData();
console.log( changeRows, changeCols, logIcons, logs );
} )
.catch( e => {
// Show error, preferably in the summary area, I think.
// Also log it.
domHandler.showError( e );
console.error( e );
} );
// Buttons should be disabled during loading.
apiHandler.isBusy() && domHandler.updateButtonDisplay();
return promise;
}
function init() {
domHandler.init();
if ( !settings.disabled ) {
domHandler.setUpDisplay();
canvasDisplay.init();
canvasDisplay.showLoading();
// Run before?
domHandler.createButtons();
buildInteractions();
showData();
console.log( 'Initializing HistoryView.js...' );
} else {
//
}
}
mw.messages.set( i18n[ mw.config.get( 'wgUserLanguage' ) ] || i18n.en );
init();
return;
// For testing.
// TODO: Move these somewhere else. Also document, expand, add names, etc.
window._hvtests_={
b:n=>{
apiHandler.getData( n ).then( ( [ results, logs ] ) => {
( { changeRows, changeCols } = processDiffs( results ) );
canvasDisplay.newData();
} );
},
createTestEnvironment() {
const lineNumber = ( n1, n2 ) => `<tr>
<td colspan="2" class="diff-lineno">Line ${ n1 }:</td>
<td colspan="2" class="diff-lineno">Line ${ n2 || n1 }:</td>
</tr>`,
addLine = ( addText = 'ADDED_TEXT' ) => `<tr>
<td colspan="2" class="diff-empty"> </td>
<td class="diff-marker">+</td>
<td class="diff-addedline"><div>${ addText }</div></td>
</tr>`,
removeLine = ( delText = 'REMOVED_TEXT' ) => `<tr>
<td class="diff-marker">−</td>
<td class="diff-deletedline"><div>${ delText }</div></td>
<td colspan="2" class="diff-empty"> </td>
</tr>`,
modLine = ( delText = 'X-X-X', addText = 'X-Y-X' ) => `<tr>
<td class="diff-marker">−</td>
<td class="diff-deletedline"><div>${ delText.replace( /-([^-])+-/g, '<del class="diffchange diffchange-inline">$1</del>' ) }</div></td>
<td class="diff-marker">+</td>
<td class="diff-addedline"><div>${ addText.replace( /-([^-])+-/g, '<ins class="diffchange diffchange-inline">$1</ins>' ) }</div></td>
</tr>`,
contextLine = ( context = 'CONTEXT' ) => `<tr>
<td class="diff-marker"> </td>
<td class="diff-context"><div>${ context }</div></td>
<td class="diff-marker"> </td>
<td class="diff-context"><div>${ context }</div></td>
</tr>`,
buildDiff = t => {
var l1 = 1, l2 = 1, cCount = 3,
t = t.split( '' );
var r = {
compare: {
['*']: t.map( ( c, i ) => {
// TODO: Deal with line number at start.
var lb = '', r = '';
if ( i === 0 && !( t[ i + 1 ] === 'c' && t[ i + 2 ] === 'c' ) ) {
r = lineNumber( l1, l2 );
}
if ( c === 'c' ) {
cCount++;
if ( cCount > 2 && ( !t[ i + 1 ] || t[ i + 1 ] === 'c' ) && ( !t[ i + 2 ] || t[ i + 2 ] === 'c' ) ) {
if ( !t[ i + 3 ] || t[ i + 3 ] === 'c' ) {
// Skip over unchanged line
} else {
// Show line header for the following line
r += lineNumber( l1 + 1, l2 + 1 );
}
} else {
// Show context line
r += contextLine();
}
} else {
cCount = 0;
r += { 'a': addLine(), 'r': removeLine(), 'm': modLine() }[ c ];
}
l1 += c !== 'a';
l2 += c !== 'r';
return r;
} ).join``
},
user: Math.random() + '',
timestamp: new Date()
};
return r;
},
runDiffTest = t => {
var r = t.map( buildDiff );
( { changeCols, changeRows } = processDiffs( r.reverse() ) );
console.log( changeRows, changeCols );
canvasDisplay.newData();
},
diffTest = t => {
var rows = t.replace(/^\n+|\n+$/g,'').split('\n'),
// Array of arrays of two-char strings
diffs = rows[ 0 ].split``.map( (_,i) => rows.map( l=> l[i] + l[i+1] ) );
diffs.pop();
var fDiffs = diffs.map( diff => diff.map( ( [ c1, c2 ] ) => {
var blank1 = c1 === ' ',
blank2 = c2 === ' ',
noChange = c1 === c2;
return ( noChange && blank1 ) ? '' :
noChange ? 'c' :
blank1 ? 'a' :
blank2 ? 'r' : 'm';
} ).join`` );
runDiffTest( fDiffs );
},
allDiffTests = () => {
// NOTE: All rows are 1-indexed, as in mw. Cols are 0-indexed, with 0
// being the difference between first and second cols in diffTest.
// Basic
diffTest( `qq\nqa` );
console.log( 'TEST1', changeRows[ 2 ].changes.length === 1 );
console.log( 'TEST1', changeCols[ 0 ].changes.length === 1 );
// Accurate line measurement
diffTest( `qq\nqq\nqq\nqa` );
console.log( 'TEST2', changeRows[ 4 ].changes.length === 1 );
// Accurate even after removal of a line.
diffTest( `q \nqq\nqq\nqq\nqq\nqq\nqa` );
console.log( 'TEST3', changeRows[ 7 ].changes.length === 1 );
// ...or an addition.
diffTest( ` q\nqq\nqq\nqq\nqq\nqq\nqa` );
console.log( 'TEST4', changeRows[ 7 ].changes.length === 1 );
// Both of these fail:
// Remove then add. (Either put in same row, or different rows, but don't lose track of number o intervening rows.)
// There should be 5 rows in between the first set of changes and the last. (Currently only 4, for both: [1,2,7].)
diffTest( `q \n q\nqq\nqq\nqq\nqq\nqq\nqa` );
console.log( changeRows );
console.log( 'TEST5', changeRows[ 1 ].changes.length === 1 && changeRows[ 8 ].changes.length === 1 );
diffTest( ` q\nq \nqq\nqq\nqq\nqq\nqq\nqa` );
console.log( 'TEST6', changeRows[ 8 ].changes.length === 1 );
// Re-add back into old row slot, don't expand.
diffTest( `q q\nqqq\nqqq\nqqq\nqqq\nqqq\nqaa` );
console.log( 'TEST7', changeRows[ 7 ].changes.length === 1 );
// Removal line, without gap.
diffTest( `q \nqq\nqa` );
console.log( 'TEST8', changeRows[ 3 ].changes.length === 1 );
// Addition, without gap.
diffTest( ` q\nqq\nqa` );
console.log( 'TEST9', changeRows[ 3 ].changes.length === 1 );
console.log( 'TEST9', changeCols[ 0 ].changes.length === 2 );
// TODO: Test headers.
// TODO: Test line moves.
// TODO: Test reverts.
//
// _hvtests_.u(`
// qqqqq
// qq qq
// qq q
// qq qq
// qcqqq
// qqqqq
// qqqqq
// qqqqq
// qqqqq
// qdefq`)
// Broken: col[ 2 ]'s last change is two rows up from where it should be.
// _hvtests_.u(`
// qqq q
// qq qq
// qq q
// qq qq
// qqq q
// qqqqq
// qqqqq
// qqqqq
// qqqqq
// qqqqq
// qdefq`)
},
protectTest = () => {
// This can only be run when there are enough diffs.
logIcons = processLogs( [
[ {
"type": "move",
"level": "sysop",
} ],
[ {
"type": "edit",
"level": "autoconfirmed",
} ],
[ {
"type": "edit",
"level": "sysop",
} ],
[ {
"type": "edit",
"level": "extendedconfirmed",
} ],
[ {
"type": "edit",
"level": "sysop",
"cascade": "cascade"
} ],
[ {
"type": "edit",
"level": "staff",
} ],
[ {
"type": "edit",
"level": "templateeditor"
} ]
// TODO: Add unprotect at the end.
].map( ( a, i ) => ( {
"params": {
"description": "\u200e[move=sysop] (expires 00:00, 29 May 2018 (UTC))",
"details": a
},
"type": "protect",
"action": "protect",
"user": "Protector",
"timestamp": changeCols[ i * 3 ].timestamp, //"2018-04-23T17:00:50Z",
"parsedcomment": "TEST" + i
} ) ).reverse() );
canvasDisplay.newData();
};
// _hvtests_.createTestEnvironment(['lcacla','lmrclm','lcrlr'])
// runDiffTest( j );
// o( j );
return {
buildDiff, diffTest, protectTest,
allDiffTests
};
}
};
// _hvtests_.createTestEnvironment().diffTest( `q \n q\nqq\nqq\nqq\nqq\nqq\nqa` );
// _hvtests_.createTestEnvironment().allDiffTests();
// _hvtests_.createTestEnvironment().protectTest();
} );