/**
* 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://en.wikipedia.org/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://en.wikipedia.org/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();
} );