User:Yair rand/DiffLists.js
Jump to navigation
Jump to search
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* globals:$, mw, DiffListsLoaded */
"use strict";
// Changes appearance of Recent Changes, Watchlist, Contributions, History
// pages, and Related Changes.
// Also adds filter options.
// This script is intended to demonstrate my proposal for watchlist design, proposed in T121361.
//
// Intended browser support: Modern browsers.
// TODO: Maintain all non-autogenerated edit summary bits. #autolist is being removed.
// TODO: More advanced filtering options. Things like Wikiproject prop groups.
// ** Allow whatever caps
// TODO: Default (when no stored settings) to Babel settings (not in links), if possible. Investigate.
// Issue: When on non-WD sites, the hiding system can hide grouped diffs if there are both
// edits to the wiki page and WD edits that are hidden by prefs.
function DiffLists() {
if ( window.DiffListsLoaded ) {
return;
}
window.DiffListsLoaded = true;
var isWD = mw.config.get( 'wgDBname' ) === 'wikidatawiki',
wdDomain = 'https://www.wikidata.org',
api = isWD ? new mw.Api() : new mw.ForeignApi( wdDomain + '/w/api.php' );
(
( [ "Recentchanges", "Watchlist", "Recentchangeslinked", "Contributions" ] )
.indexOf( mw.config.get( "wgCanonicalSpecialPageName" ) ) !== -1 ||
( mw.config.get( "wgAction" ) === "history" && [ 0, 120 ].indexOf( mw.config.get( "wgNamespaceNumber" ) ) !== -1 )
) && api.get( {
action: "query",
meta: "allmessages",
amlang: mw.config.get( 'wgUserLanguage' ),
ammessages: [
// Changing the order shouldn't break things. TODO: Fix.
// Diff view stuff.
"wikibase-diffview-label",
"wikibase-diffview-description",
"wikibase-diffview-alias",
"wikibase-diffview-link",
"wikibase-entity-property",
"wikibase-diffview-qualifier",
"wikibase-diffview-reference",
"wikibase-diffview-rank",
// Extras
"wikibase-entitytermsforlanguagelistview-label",
"wikibase-entitytermsforlanguagelistview-description",
"wikibase-statementsection-statements",
"wikibase-diffview-rank-preferred",
"wikibase-diffview-rank-normal",
"wikibase-diffview-rank-deprecated",
"wikibase-diffview-link-badges"
].join( "|" ),
maxage: 60 * 60 * 24 * 30,
smaxage: 60 * 60 * 24 * 30
} ).done( function ( msgData ) {
$( function () {
//console.log( "msgs", msgData );
mw.util.addCSS(
".YR-cl-added { background-color: #d8ecff; margin: 0 1px; padding: 0 1px; }" +
".YR-cl-removed { background-color: #feeec8; margin: 0 1px; padding: 0 1px; text-decoration: line-through; }" +
".YR-cl-removed a .diffchange-inline { text-decoration: underline; }" +
// Yeah, this is misusing !important, but I need to override jquery's straight style.display.
".YR-cl-hidden { display: none !important; }" +
".YR-cl-summary ~ .YR-cl-summary:before { content: \", \"; }" +
".YR-cl-hiddensummary { display: none; }" +
// The byte markers are even more useless when the diff visible right there. Hide them.
".YR-cl-enhanceddiff .mw-diff-bytes, .YR-cl-enhanceddiff .mw-diff-bytes + .mw-changeslist-separator, " +
".YR-cl-enhanceddiff .mw-changeslist-line-inner-characterDiff" +
"{ display: none; }"
);
const
lang = mw.config.get( 'wgUserLanguage' ),
/**
* Whether the user has "Group changes by page in recent changes and watchlist"
* turned on in their preferences.
* @type {'1'|0}
*/
groupChangesUserPreference = mw.user.options.get( 'usenewrc' ),
/** @type {string[]} */
msgArray = msgData.query.allmessages.map( msg => msg[ "*" ] ),
/**
* @type { { [msgName:string]: string } }
*/
msgs = Object.fromEntries( msgData.query.allmessages.map( function ( msg, i ) {
return [
i < 8 ? msg.name.split( "-" )[ 2 ] : msg.name,
msg[ "*" ]
];
} ) ),
allPropTypes = {
[ msgs.label ]: "Labels",
[ msgs.description ]: "Descriptions",
[ msgs.link ]: "Sitelinks",
[ msgs.alias ]: "Aliases",
[ msgs.property ]: msgs[ "wikibase-statementsection-statements" ]
},
// (These indexes must match the msg api query order.)
LABEL = 0,
DESCRIPTION = 1,
ALIAS = 2,
LINK = 3,
PROPERTY = 4,
listType = mw.config.get( "wgAction" ) === "history" ? "history" :
( mw.config.get( "wgCanonicalSpecialPageName" ) === "Contributions" ?
"contribs" :
// Meaning watchlist, recent changes, or related changes.
"normal"
),
// Selector for all the individual links to particular diffs.
// (Not including "grouped" diffs.)
changeSelector =
isWD ?
listType === "history" ?
// History pages...
".mw-history-histlinks > span + span > a"
//".mw-history-histlinks a:last-child"
:
listType === "contribs" ?
// Contributions pages...
".mw-changeslist-diff"
//".mw-contributions-list a"
:
// Normal watchlists/Recent changes
".mw-changeslist .special > li > .mw-changeslist-line-inner > a:first-child, " +
// Enhanced watchlists/Recent changes
// TODO: This is no longer accurate. difflinks are now .mw-changeslist-diff.
// Groups are .mw-changeslist-groupdiff
//".mw-enhanced-rc .mw-title + a, " +
".mw-changeslist-diff"
:
// '.mw-changeslist .wikibase-edit a[tabindex]';
".mw-changeslist .mw-changeslist-src-mw-wikibase a[tabindex]";
var
createChangeBlock = ( function () {
var addedTemplate = $( "<span>" ).addClass( "YR-cl-added" )[ 0 ],
removedTemplate = $( "<span>" ).addClass( "YR-cl-removed" )[ 0 ];
/**
* Wrap an element in a "added"/"removed" marker, with the relevant styles.
* @param {"added"|"removed"} type
* @param {Element|Text} elem The element to wrap.
* @return {Element} The new outer, wrapping element.
*/
return function ( type, elem ) {
var result = ( type === "added" ? addedTemplate : removedTemplate ).cloneNode( true );
result.appendChild( elem );
return result;
// return ( type === "added" ? addedTemplate : removedTemplate ).cloneNode( true ).appendChild( elem ).parentNode;
};
})();
var
/**
* Data for every edit listing on the page.
* @type {Diff[]}
*/
allDiffs = [],
/**
* For those with "Group changes by page in recent changes and watchlist" enabled in
* their preferences, some diffs are nested under a "group" diff's element.
* To reconstruct the group diff from the component diffs, this map records
* each group diff element's set of changes.
* @type {Map<Element, { markers: { timestamp:string, firstElem: Element }[], diff: Diff }>}
*/
nestedParents = new Map();
/**
* @typedef {object} Diff
* Data relating to a particular diff.
* @property {Element} elem The edit listing itself.
* @property {Chunk[]} chunks Elements containing summaries of the diff.
* @property { DiffComponents } components
*/
/**
* @typedef { [
* labels:OtherChanges, descriptions:OtherChanges, aliases:AliasChanges, links:SiteLinkChanges,
* properties:{ [propertyKey:string]: StatementChange }
* ] } DiffComponents
* A set of all the individual changes that were done in the edit,
* sorted by category.
*/
/**
* @typedef StatementChange
* List of changes done within a specific property.
* @property { { [value:string]: StatementChangeValue } } values
* @property {Element} link A link to the statement's property.
*/
/**
* @typedef StatementChangeValue
* @property { "removed" | "added" } [change] (Undefined if the mainsnak is unchanged.)
* @property {Element} node Representing the value of the mainsnak.
* @property {( { propLink: Element, value: Element }[] & { change: "removed" | "added" } )[]} [references]
* @property { { [qualPropName:string]: { change: "removed" | "added", value: Element }[] & { link: Element } } } [qualifiers]
* @property { { removed?: { rank: number, rankName: string }, added?: { rank: number, rankName: string } } } [rank]
*/
/**
* @typedef { { [langOrWiki:string]: { removed?: Element, added?: Element }[] } } AliasChanges
* A change to a set of aliases. Multiple additions in the same language can happen in the same
* diff, so each language's changes work as an array.
*/
/**
*
* @typedef { { [site:string]: { added?: Element, removed?: Element, badges?: { added?: Element, removed?: Element }[] } } } SiteLinkChanges
* The site is the 'site code' (eg 'enwiki'). Each sitelink can have a set of badges.
*/
/**
* @typedef { { [langcode:string]: { added?: Element, removed?: Element, modified?: Element } } } OtherChanges
* A change to a label or description.
*/
/**
* @typedef {object} Chunk
* @property {Element} elem DOM element with a summary of the changes.
* @property {(LABEL|DESCRIPTION|LINK|ALIAS|PROPERTY)} type
* @property {string} data For statements: Property. For links: site. Others: Language code.
* @property {SiteLinkChanges|AliasChanges|OtherChanges|{ [propertyKey:string]: StatementChange }} change
* The diff component that the chunk was built from.
* @property {Element} [wrapper] Span wrapping around the content. This element's display style
* is toggled to show or hide the chunk. (Property set in addChunks().)
*/
/**
* Set up the entire preferences system for hiding certain edits or parts of edits.
* Returns a function for updating whether a set of diffs is displayed.
*/
function prepareHidingPreferences() {
var filterData = {
dbTypes: [ '', 'voyage', 'books', 'quote', 'news', 'source', 'versity' ].map( x => 'wiki' + x ),
specialwikis: [ 'commons', 'meta', 'mediawiki', 'wikidata', 'species' ],
propGroups: {
// These can be functions or arrays.
identifiers: function ( x ) {
var v = x.change.values, node;
for ( var i in v ) {
node = v[ i ].node;
if ( node?.nodeType === 1 && (
node.classList.contains( 'wb-external-id' ) ||
node.firstElementChild?.classList.contains( 'wb-external-id' )
) ) {
return true;
}
}
},
other: function ( x ) {
return filterData.specialwikis.some( y => y + 'wiki' === x.data );
}
}
};
// Settings will be stored in localStorage, in the following format:
// { "label": "..." | "" | false, ... }
var
/**
* Currently formatted as, eg, { "label": "en|fr", "description": "", ... }
* only, it uses the internationalized forms of "label" etc, which it _really_
* shouldn't.
* TODO: Fix that.
* TODO: Move into the scope of buildOptionsBox.
* An empty string means show everything.
* A value of "false" for a category means hide everything.
*/
simpleSettings = getSettings(),
processedSettings = processSettings( simpleSettings );
/**
* Build the area for editing user preferences.
*/
function buildOptionsBox() {
function setSettings() {
var data = {};
$.each( checkboxes, function ( i, checkbox ) {
data[ i ] = checkbox.checked && inputs[ i ].value;
} );
simpleSettings = data;
localStorage[ "YR-cl-settings" ] = JSON.stringify( simpleSettings );
processedSettings = processSettings( simpleSettings );
}
var optionsContainer,
optionsDiv = document.createElement( "div" ),
/** @type {Object.<number,HTMLInputElement>} */
checkboxes = {},
inputs = {};
if ( listType === "normal" ) {
optionsContainer = document.querySelector( "#mw-watchlist-options, .rcoptions, .rcfilters-head, .mw-rcfilters-head" );
// Add a divider.
optionsContainer.appendChild( document.createElement( "hr" ) ).style.margin = "8px 0";
} else {
// var l = document.querySelector( listType === "contribs" ? ".mw-contributions-form" : "#mw-history-searchform" );
var l = document.querySelector( ".mw-htmlform-ooui-wrapper" );
if ( l ) {
optionsContainer = l.parentNode.insertBefore( document.createElement( "fieldset" ), l.nextSibling );
optionsContainer
.appendChild( document.createElement( "legend" ) )
.append( "Filter options" );
}
}
// Build filter options menu
$.each( allPropTypes, function ( type, optionsLabel ) {
// var typeIndex = msgArray.indexOf( type );
var optionDiv = document.createElement( "div" );
optionDiv.style.display = "inline-block";
optionsDiv.appendChild( optionDiv );
var checkbox = checkboxes[ type ] = document.createElement( "input" );
checkbox.type = "checkbox";
checkbox.checked = ( !simpleSettings || simpleSettings[ type ] !== false ) ? true : false;
var input = inputs[ type ] = document.createElement( "input" );
optionDiv.append(
checkbox,
optionsLabel + ": ",
input
);
if ( [ msgs.label, msgs.description, msgs.alias ].includes( type ) ) {
// if ( [ LABEL, DESCRIPTION, ALIAS ].includes( typeIndex ) ) {
input.title =
'Show only languages with these languages codes, ' +
'separated by |, e.g. "en|fr". Leave blank to show all.';
} else if ( type === msgs.link ) {
input.title =
'Show only links to projects with these database ' +
'codes, separated by |, e.g. "enwiki|frwikivoyage". ' +
'Leave blank to show all.\n' +
'You can use a language code to show links to all ' +
'projects in that language, or a project database ' +
'suffix to show links to all of those projects ' +
'regardless of language. Use "other" to show links ' +
'to Commons, Meta, Mediawiki, Wikispecies, and ' +
'Wikidata.';
} else {
input.title =
'Show only changes to statements using these ' +
'properties, separated by |, e.g. "P40|P569". ' +
'Leave blank to show all.\n' +
'You can use "identifiers" to select all identifier ' +
'properties, and prefix a property or group of ' +
'properties with "^" to not show the property. ' +
'(E.g. Input just "^identifiers" to show all ' +
'properties except identifiers.)';
}
input.style.width = "50px";
input.value = simpleSettings && simpleSettings[ type ] || "";
if ( simpleSettings && simpleSettings[ type ] === false ) {
input.disabled = true;
}
checkbox.onchange = input.onchange = function () {
input.disabled = !checkbox.checked;
setSettings();
updateDisplay( allDiffs );
};
input.oninput = function ( e ) {
setSettings();
updateDisplay( allDiffs );
}
input.onkeydown = function ( e ) {
if ( e.key === "Enter" ) {
setSettings();
updateDisplay( allDiffs );
return false;
}
};
} );
// Add the menu to the DOM
if ( optionsContainer ) {
optionsContainer.appendChild( optionsDiv );
}
}
// Multiple situations possible here:
// * Checking to see if any members of a group match show=true
// ** 'changes' obj, check to see if there's any of x type, etc
// * Check if a particular prop/value should be shown
// Among this, there can be single setting prop or multi-setting prop.
// Is it actually necessary to special-case the first check?
//
/**
* Checks if a value should be shown, under the user's current preferences.
* @param {Chunk} value
* @returns {boolean}
*/
function checkSetting( value ) {
var setting = processedSettings[ msgArray[ value.type ] ];
if ( setting === false ) {
// The entire category is unchecked. Hide all.
return false;
} else if ( setting === '' || setting === undefined ) {
// Preferences haven't been filled in. Show by default.
return true;
} else {
var shouldBeVisible = !setting[ 0 ].additive,
propGroups = filterData.propGroups;
setting.forEach( settingV => {
if ( settingV.type === 'plain' ) {
if ( value.data === settingV.content ) {
shouldBeVisible = settingV.additive;
}
} else if ( settingV.type === 'group' ) {
// Probably statements only
if ( typeof propGroups[ settingV.content ] === 'function' ?
propGroups[ settingV.content ]( value ) :
propGroups[ settingV.content ].indexOf( value.data ) !== -1
) {
shouldBeVisible = settingV.additive;
}
} else if ( settingV.type === 'lang' ) {
// Links only
// I think this needs access to dbtypes, to deal with unusual cases.
if ( value.data.startsWith( settingV.content ) ) {
if ( filterData.dbTypes.some( x => settingV.content + x === value.data ) ) {
shouldBeVisible = settingV.additive;
}
}
} else if ( settingV.type === 'wiki' ) {
// Links only
if ( value.data.endsWith( settingV.content ) ) {
shouldBeVisible = settingV.additive;
}
} else if ( settingV.type === 'specialwiki' ) {
// Links to "other" wikis, eg Commons.
if ( value.data === settingV.content + 'wiki' ) {
shouldBeVisible = settingV.additive;
}
}
} );
return shouldBeVisible;
}
}
/**
* Retrieve user preferences from localStorage or URL settings.
* Each value is plain strings straight from the options boxes
* (or equivalents) unparsed.
* @return {Object<string,string|false>|false}
*/
function getSettings() {
var urlArgs = mw.util.getParamValue( 'difflists' ),
storage = localStorage[ "YR-cl-settings" ];
//
if ( !urlArgs && !storage ) {
return false;
} else {
return $.extend( {},
storage && JSON.parse( storage ),
urlArgs && JSON.parse( urlArgs ) );
}
}
/**
*
* @param {Object<string, string>|false} simpleSettings
* @returns {false|Object<string,
* {
* additive: boolean,
* type: "plain"|"group"|"lang"|"wiki"|"specialwiki",
* content: string
* }[]|
* false
* >}
*/
function processSettings( simpleSettings ) {
var settings = {},
{ dbTypes, propGroups, specialwikis } = filterData;
if ( simpleSettings === false ) {
// No hide settings. Show everything.
return false;
}
$.each( simpleSettings, function ( type, input ) {
var setting = settings[ type ] = input && input.split( /\s*[\|\,]\s*/ );
if ( setting ) {
settings[ type ] = setting.map( x => {
var isProp = type === msgs.property,
isLink = type === msgs.link,
additive = x[ 0 ] !== '^',
content = additive ? x : x.slice( 1 );
if ( isProp && !propGroups[ content ] ) {
content = 'Property:' + content;
}
return {
additive,
type:
// What was this line intended to do?
// Doesn't seem to make any sense?
( propGroups.hasOwnProperty( content ) && 'group' ) ||
( isLink && (
( specialwikis.indexOf( x ) !== -1 && 'specialwiki' ) ||
( dbTypes.indexOf( x ) !== -1 && 'wiki' ) ||
( !dbTypes.some( y => x.endsWith( y ) ) && 'lang' )
) ) ||
'plain',
content
};
} );
}
// Values:
// false - hide the entire category
// { additive: true, type: plain, content: en }
// { additive: true, type: lang, content: en }
// { additive: true, type: wiki, content: wiki }
// { additive: false, type: group, content: identifiers }
// { additive: true, type: specialwiki, content: meta }
} );
return settings;
}
/**
* Show or hide diff, depending on filter preferences.
* @param {Diff[]} diffs
*/
function updateDisplay( diffs ) {
var isHistory = listType === "history";
processedSettings && diffs.forEach( function ( diff ) {
/**
* The diff should be hidden, unless at least one chunk
* needs to be shown
*/
var displayDiff = false;
// If there's some not filtered, or we're on a history page
// where all diffs are shown, hide individual filtered segments.
$.each( diff.chunks, function ( i, chunk ) {
var display = checkSetting( chunk );
displayDiff ||= display;
chunk.wrapper.className = display ? "YR-cl-summary" : "YR-cl-hiddensummary";
} );
// If all elements are filtered, hide the whole listing, unless
// we're on a history page.
if ( !isHistory ) {
diff.elem.classList.toggle( "YR-cl-hidden", !displayDiff );
}
} );
}
buildOptionsBox();
return updateDisplay;
}
/**
* Takes two td elements with text content, and 'merges' the diff parts to show
* added and removed text in the same string.
* @param {Element} rm
* @param {Element} add
* @returns {Element}
*/
function mergeChangesDom( rm, add ) {
rm = rm.firstChild;
add = add.firstChild;
let newDom = document.createElement( 'span' );
for ( let curRm = rm.firstChild, curAdd = add.firstChild; curRm || curAdd; ) {
let rmIsElem = curRm?.nodeType === 1,
addIsElem = curAdd?.nodeType === 1;
if ( !curRm || !curAdd ) {
newDom.appendChild( createChangeBlock( curRm ? 'removed' : 'added', curRm || curAdd ) );
} else if ( rmIsElem || addIsElem ) {
if ( rmIsElem ) {
newDom.appendChild( createChangeBlock( 'removed', curRm ) );
}
if ( addIsElem ) {
newDom.appendChild( createChangeBlock( 'added', curAdd ) );
}
} else {
let rmText = curRm.nodeValue,
addText = curAdd.nodeValue;
if ( rmText === addText ) {
newDom.appendChild( curRm );
add.removeChild( curAdd );
} else if ( addText.startsWith( rmText ) ) {
newDom.appendChild( curRm );
curAdd.nodeValue = addText.slice( rmText.length );
} else if ( rmText.startsWith( addText ) ) {
newDom.appendChild( curAdd );
curRm.nodeValue = rmText.slice( addText.length );
} else {
// Things don't match up. Might be because of weirdness
// with handling spaces.
// Trim the texts, and try again.
var tRmText = rmText.trim(),
tAddText = addText.trim();
if ( tRmText !== rmText || tAddText !== addText ) {
curRm.nodeValue = tRmText;
curAdd.nodeValue = tAddText;
} else {
// It didn't work.
// No idea what could cause this, but just in case...
newDom.append( '[error]' );
add.removeChild( curAdd );
rm.removeChild( curRm );
}
}
}
curRm = rm.firstChild;
curAdd = add.firstChild;
}
return newDom;
}
/**
* Build 'chunks' (dom containing summaries) for these diff components.
* @param {DiffComponents} changes The parsed diff's components, to base the chunks off of.
* @return {Chunk[]}
*/
function buildChunks( changes ) {
/** @type {Chunk[]} */
var chunks = [];
// First non-statement changes.
[ LABEL, DESCRIPTION, LINK, ALIAS ].forEach( function ( type ) {
$.each( changes[ type ], function ( langOrWiki, change ) {
var elem = document.createElement( "span" ),
text = ( {
[ LABEL ]: msgs[ "wikibase-entitytermsforlanguagelistview-label" ],
[ DESCRIPTION ]: msgs[ "wikibase-entitytermsforlanguagelistview-description" ],
[ LINK ]: "Link",
[ ALIAS ]: "Aliases"
} )[ type ];
chunks.push( { elem, type, data: langOrWiki, change } );
elem.append( text + " [" + langOrWiki + "]: " );
$.each( type === ALIAS ? change : [ change ], function( i, a ) {
[ "added", "removed" ].forEach( function ( type ) {
a[ type ] && elem.appendChild( createChangeBlock( type, a[ type ] ) );
} );
if ( a.modified ) {
elem.appendChild( a.modified );
}
} );
// For links, add badge DOM.
if ( 'badges' in change ) {
change.badges.forEach( function ( badgeChange ) {
$.each( badgeChange, function ( addedOrRemoved, badgeElem ) {
// We're using the original element. Should this
// be cloned? If not, this might cause problems
// for multiple diffs using the same changes.
badgeElem.prepend( "(badge: " );
badgeElem.append( ")" );
elem.appendChild( createChangeBlock( addedOrRemoved, badgeElem ) );
} );
} );
}
} );
} );
// Then statement changes.
$.each( changes[ PROPERTY ], function ( property, change ) {
var elem = document.createElement( "span" ),
keys = Object.keys( change.values ),
// If there's only a single statement being added or removed for
// this property, encase also the property name within the block.
encase = keys.length === 1 && change.values[ keys[ 0 ] ].change;
elem.append(
change.link,
": "
);
$.each( change.values, function ( valueKey, statement ) {
if ( statement.change ) {
if ( !encase ) {
// Color the mainsnak's value.
elem
.appendChild( createChangeBlock( statement.change, statement.node ) );
} else {
// Wrap the whole thing (including property) in a
// 'added'/'removed' color.
elem.appendChild( statement.node );
elem = createChangeBlock( statement.change, elem );
}
} else {
// Leave the prop and value uncolored. The only changes are to
// qualifiers/references/ranks.
elem.appendChild( statement.node );
}
if ( statement.qualifiers ) {
var first = true;
// The diff system does something really weird with multis.
// Deletes and recreates all the same qualifiers.
$.each( statement.qualifiers, function ( i, qualPropSet ) {
elem.append( first ? " / " : ", " );
var link = qualPropSet.link, // Link to the qualifier's property
qualHolder = qualPropSet.length === 1 ?
elem.appendChild( createChangeBlock( qualPropSet[ 0 ].change, link ) ) :
elem;
first = false;
qualHolder.append(
link,
": "
);
qualPropSet.forEach( function ( a ) {
elem.appendChild( createChangeBlock( a.change, a.value ) );
} );
} );
}
if ( statement.references ) {
$.each( statement.references, function ( i, reference ) {
var refElem = createChangeBlock( reference.change, document.createTextNode( "Reference: " ) );
reference.forEach( function ( refSnak, i ) {
refElem.append(
i === 0 ? "" : ", ",
refSnak.propLink,
": ",
refSnak.value
);
} );
elem.append(
i ? ", " : " / ",
refElem
);
} );
}
if ( statement.rank ) {
$.each( statement.rank, function ( addedOrRemoved, rankChange ) {
var rankDot = document.createElement( [ "sup", "span", "sub" ][ rankChange.rank ] );
rankDot.append( "·" );
rankDot.title = rankChange.rankName;
statement.node.before(
createChangeBlock( addedOrRemoved, rankDot )
);
} );
}
} );
if ( !isWD ) {
$( elem ).find( 'a[ href ]:not( [ href ^= "http" ] )' ).each( function () {
this.href = wdDomain + this.getAttribute( 'href' );
} );
}
chunks.push( { elem, type: PROPERTY, data: property, change } );
} );
return chunks;
}
/**
* Edit the DOM, fill in with the summarized diffs.
* @param {Chunk[]} chunks
* @param {Element} outerElement
* @param {{ insertBefore?: Element, markers: { timestamp: number, firstElem: Element }[] } } [nestedChunkData]
*/
function addChunks( chunks, outerElement, nestedChunkData ) {
// TODO: Reuse summary nodes in nesteds where this'll run multiple times.
var summaryNode = document.createElement( "span" ),
originalEditSummary = outerElement.querySelector( ".comment" ),
beforePoint,
isFirstPassthrough = true;
// For parents of nested diffs, add new chunks to old summary.
if ( nestedChunkData ) {
var markers = nestedChunkData.markers;
// If there's an immediately-following chunk present (note that parts
// is sorted chronologically by diff timestamp), put the new one
// immediately before it.
beforePoint = nestedChunkData.insertBefore;
if ( markers.length !== 0 ) {
// This isn't the first time we've done stuff to this listing.
isFirstPassthrough = false;
// Use an existing generated summary element if there is one.
summaryNode = markers[ 0 ].firstElem.parentNode.parentNode;
}
}
if ( isFirstPassthrough ) {
summaryNode.append( " - " );
}
// Place the chunks in the proper location.
chunks.forEach( function ( chunk ) {
var elem = chunk.elem,
wrapper = chunk.wrapper = document.createElement( "span" );
wrapper.className = "YR-cl-summary";
wrapper.appendChild( elem );
if ( beforePoint ) {
beforePoint.before( wrapper );
} else {
summaryNode.appendChild( wrapper );
}
} );
// Replace old summary with new summary.
if ( originalEditSummary && isFirstPassthrough ) {
originalEditSummary.before( summaryNode );
// If the original edit summary just an auto-generated summary (which
// is worse than the one we built here), hide it.
// (This should leave things like "#autolist2", if possible.)
if ( originalEditSummary.querySelector( '.autocomment' ) ) {
originalEditSummary.style.display = 'none';
}
} else {
// ummm, somehow there's no .comment. Put it at the end?
// (not a good solution, as outerElement will sometimes be a tr.)
outerElement.appendChild( summaryNode );
}
}
/**
* Parse the diff's HTML into a more usable format.
* @param {string} diffHtml
* @returns {DiffComponents}
*/
function parseDiff( diffHtml ) {
/**
* The diff page's DOM.
* @type {JQuery<Element>}
*/
var $x = $( diffHtml ),
/**
* @type {DiffComponents}
*/
diffComponents = [ {}, {}, {}, {}, {} ];
// Build diffComponents from result data.
$x.each( function ( trIndex, tr ) {
if ( tr.nodeName === "TR" && tr.nextSibling && tr.childNodes.length === 2 && tr.firstChild.className !== "diff-marker" ) {
// We're dealing with a heading line
var
/**
* Above each change line, there's a bolded line indicating what part of the
* item is being changed. Examples: "description / en", "links/enwiki/name",
* "Property: instance of: city / qualifiers". This line can contain links.
* @type {HTMLTableCellElement}
*/
headerNode = tr.firstChild.firstChild ? tr.firstChild : tr.lastChild,
propData = headerNode.textContent.split( " / " ),
//mainType = propData[ 0 ],
mainType = msgArray.indexOf( propData[ 0 ] ),
/**
* The actual diff line, showing the added/removed elements.
*/
trValueBlock = tr.nextSibling,
valueTds = {
removed: trValueBlock.firstChild.className === "diff-marker" && trValueBlock.childNodes[ 1 ],
added: trValueBlock.lastChild.className === "diff-addedline" && trValueBlock.lastChild
};
switch ( mainType ) {
case PROPERTY:
// Bunch of different situations here:
// If a brand new statement, highlight all.
// If removing full statement, highlight and strike all.
// If changing a statement, don't highlight prop, highlight values.
// If just adding/removing/changing qualifiers/sources, don't highlight values either.
// Mixing these has complex results.
// Note: Depending on data type, value might not be a link.
// Maybe build the skeleton in advance?
// Overall structure: diffComponents = [ TODO ]
/**
* The format of the headers for statements is
* "Property / " < link to property >
* [ ": " < value > " / " < sub-component > ]
*/
var
/**
* A link to the statement's property.
*/
propLink = headerNode.firstChild.nextSibling,
/** The property's name/ID. (Eg, "Property:P123".) */
prop = propLink.title,
/** @type {StatementChange} */
propChangeGroup =
( diffComponents[ mainType ][ prop ] ||= { values: {}, link: propLink } );
if ( !propLink ) {
//console.log( "no proplink", node );
}
$.each( valueTds, function ( removedOrAdded, td ) {
var valNode,
/** @type {StatementChangeValue} */
valGroup;
if ( !td || !td.firstChild?.firstChild ) {
//console.log( "break", td );
return;
}
$( [ headerNode, td ] ).find( ".wb-language-fallback-indicator" ).remove();
// Assuming that only two nodes means it's a simple statement.
if ( headerNode.childNodes.length === 2 ) {
// Simple statement
var
val = td.firstChild.firstChild.firstChild,
/** @type {string} */
valName;
// What's the datatype?
// Different datatypes have very different output formats
// to be parsed.
if ( val.firstChild?.nodeName === "A" ) {
// We have a straight link. Probably item datatype.
valNode = val.firstChild;
// Images and whatnot don't have titles.
valName = valNode.title || valNode.textContent;
} else if ( val.firstChild && val.lastChild.nodeName === "TABLE" ) {
// Table filled with stuff, probably quantity or date.
// (Date uses <b /><table />, quantity uses <h4 /><table />)
valName = val.firstChild.textContent;
valNode = document.createElement( "span" );
valNode.append( valName );
} else {
// Straight string, eg string datatype.
valName = val.textContent;
valNode = val;
}
valGroup =
propChangeGroup.values[ valName ] ||= {};
valGroup.change = removedOrAdded;
} else {
// Qualifier, Source, or Rank statement
// The value is sometimes a string, sometimes a link.
var subtype = propData[ propData.length - 1 ],
hasValueLink = headerNode.childNodes[ 3 ] && headerNode.childNodes[ 3 ].nodeName === "A",
valName = hasValueLink ?
headerNode.childNodes[ 3 ].title || headerNode.childNodes[ 3 ].textContent :
propData[ 1 ].split( ": " )[ 1 ];
valGroup =
propChangeGroup.values[ valName ] ||= {};
valNode = hasValueLink ?
headerNode.childNodes[ 3 ] :
document.createTextNode( valName );
/**
* Get the value of the qualifier/reference part.
* There are 3 options: ": <a>content</a>", ": <h4>content</h4><table />", and ": content"
* Only copy the element itself if it's a link.
* Occasionally there's actually a span element indicating an error.
* If so, try for the previous element.
* (Apologies for the terrible function name.)
* @param {Element} t
* @returns {Element | Text}
*/
function getStuff( t ) {
return t.nodeName === "A" ?
t :
document.createTextNode(
( t.nodeName === "TABLE" || t.nodeName === "SPAN" ) ?
t.previousElementSibling.textContent :
// This breaks sometimes. TODO.
t.nodeValue.slice( 2 )
//t.nodeValue.split( ":" )[ 1 ]
);
}
// TODO: Try to merge these, if possible.
switch ( subtype ) {
case msgs.reference: {
// A statement can have any number of references.
// References have a list of statements, but are one block each.
let subGroup = valGroup.references ||= [],
ref = [];
td && subGroup.push( ref );
ref.change = removedOrAdded;
// Series of spans, separated by brs.
$( ">div>.diffchange>span", td ).each( function () {
ref.push( {
propLink: this.firstChild,
value: getStuff( this.lastChild )
} );
} );
break;
}
case msgs.qualifier: {
// Should this be an array or object containing arrays? There can be multiple
// qualifiers of the same type, +added/removed values, ...
// Still, changed values need to be shown...
let subGroup = valGroup.qualifiers ||= {},
qualSpan = td.firstChild.firstChild.firstChild,
// The value will always start with a link to the property.
subPropLink = qualSpan.firstChild,
subPropName = subPropLink.title,
qualList = subGroup[ subPropName ] ||= [],
qual = {};
qualList.push( qual );
qualList.link = subPropLink;
qual.change = removedOrAdded;
qual.value = getStuff( qualSpan.lastChild );
// Clear duplicates
qualList.forEach( function ( a, i ) {
if ( ( a.value.title || a.value.nodeValue ) === ( qual.value.title || qual.value.nodeValue ) && a.change !== qual.change ) {
qualList.splice( qualList.length - 1, 1 );
qualList.splice( i, 1 );
if ( qualList.length === 0 ) {
delete subGroup[ subPropName ];
}
return false;
}
} );
// After, there's a ": ", but I don't know what happens to strings.
break;
}
case msgs.rank:
var subGroup = valGroup.rank ||= {},
rankSpan = td.firstChild.firstChild.firstChild,
rankName = rankSpan.textContent,
rank = ( [
msgs[ "wikibase-diffview-rank-preferred" ],
msgs[ "wikibase-diffview-rank-normal" ],
msgs[ "wikibase-diffview-rank-deprecated" ]
] ).indexOf( rankName );
subGroup[ removedOrAdded ] = { rank, rankName };
break;
}
}
valGroup.node ||= valNode;
} );
break;
case LABEL:
case DESCRIPTION:
case LINK:
case ALIAS:
var langOrWiki = propData[ 1 ],
isAlias = mainType === ALIAS,
newBlock =
diffComponents[ mainType ][ langOrWiki ] ||= ( isAlias ? [] : {} ),
isBadge = propData[ 2 ] === msgs[ 'wikibase-diffview-link-badges' ]; // "badges"
if ( valueTds.removed && valueTds.added ) {
newBlock.modified = mergeChangesDom(
valueTds.removed, valueTds.added
);
} else {
$.each( valueTds, function ( addedOrRemoved, td ) {
if ( !td.firstChild?.firstChild ) {
//console.log( 777, td, diffLink.parentNode, $x );
}
if ( td ) {
if ( !isBadge ) {
// Normal label, description, alias, or sitelink
if ( td ) {
if ( isAlias ) {
newBlock.push( newBlock = {} );
}
newBlock[ addedOrRemoved ] = td.firstChild.firstChild;
}
} else {
// Badge.
newBlock.badges ||= [];
var newBadge = {
[ addedOrRemoved ]: td.firstChild.firstChild.firstChild
};
newBlock.badges.push( newBadge );
}
}
} );
}
break;
}
}
} );
return diffComponents;
}
/**
* Take an individual listed diff, grab its data, and enhance the
* listed diff.
* @param {Element} diffLinkParent
* @param {string} oldid
* @param {string} pageid
* @param {string} diffid
* @param {string} title
*/
function processDiff( diffLinkParent, oldid, pageid, diffid, title ) {
api.get( {
action: "query",
prop: "revisions",
//rvprop: "timestamp|user|comment",
//pageids: pageid,
titles: title,
rvstartid: oldid,
//rvdiffto: "next",
rvdiffto: diffid,
rvlimit: 1,
uselang: lang,
maxage: 60 * 60 * 24 * 3,
smaxage: 60 * 60 * 24 * 3
} ).done( function ( result ) {
// if ( !result || !result.query || !result.query.pages || result.query.pages[ -1 ] ) {
if ( !result?.query?.pages || result.query.pages[ -1 ] ) {
console.error( 'DiffLists: Diff not found.', result );
return;
}
// TODO: getStuff stuff.
// TODO: Grab localizations for everything.
//
var revision = Object.values( result.query.pages )[ 0 ].revisions[ 0 ],
/** @type {string} */
timestamp = revision.timestamp,
/** @type {string} */
diffHtml = revision.diff[ "*" ],
// enhanced = diffLinkParent.nodeName === "TD",
nested = groupChangesUserPreference && diffLinkParent.className === "mw-enhanced-rc-nested",
// The element that holds the whole edit listing, for hiding purposes. Three different situations:
// Regular watchlist, enhanced watchlist, and enhanced nested.
// Enhanced lists are lists of tables, the first row of each being main, others nested.
outer = diffLinkParent.closest( 'tr, li' ),
/**
* @type {DiffComponents}
*/
diffComponents = parseDiff( diffHtml ),
/**
* Array of DOM to be added
* @type {Chunk[]}
*/
chunks = buildChunks( diffComponents ),
/** @type {Diff} */
diff = { elem: outer, chunks, components: diffComponents };
allDiffs.push( diff );
if ( chunks.length ) {
// Add to the visible DOM.
// addChunks( chunks, diffLinkParent );
if ( !outer ) {
console.log('oop', diff, outer, diffLinkParent );
}
addChunks( chunks, outer );
// Hide whatever needs to be hidden, per the user's preferences/settings.
updateDisplay( [ diff ] );
outer.classList.add( 'YR-cl-enhanceddiff' );
// For those who have checked "Group changes by page in recent changes and watchlist"
// in preferences, there are nested listings. We reconstruct the parent
// by combining the contents of each of the individual listings.
if ( nested ) {
var
// Clone the individual nodes in our generated summary, for the parent diff.
dupChunks = ( chunks.map( x => ( { ...x, elem: x.elem.cloneNode( true ) } ) ) ),
toAdd = { timestamp, firstElem: dupChunks[ 0 ].elem },
tbody = outer.parentNode,
// These lines are the Map way of saying nesteds = nestedParents[ tbody ] ||= { ... };
nesteds = nestedParents.get( tbody ),
/** Index of element to insert the new chunks immediately before. */
insertionIndex = 0;
if ( !nesteds ) {
nesteds = {
diff: { chunks: [], components: [ {}, {}, {}, {}, {} ], elem: tbody },
markers: []
};
nestedParents.set( tbody, nesteds );
allDiffs.push( nesteds.diff );
}
for ( ; insertionIndex < nesteds.markers.length; insertionIndex++ ) {
if ( timestamp < nesteds.markers[ insertionIndex ].timestamp ) {
// insertionIndex = insertionIndex;
break;
}
}
var insertionElement = nesteds.markers[ insertionIndex ]?.firstElem.parentNode;
// addChunks( dupChunks, tbody.firstElementChild.lastElementChild, nesteds, toAdd );
addChunks( dupChunks, tbody.firstElementChild.lastElementChild, {
insertBefore: insertionElement,
markers: nesteds.markers
} );
nesteds.markers.splice( insertionIndex, 0, toAdd );
nesteds.diff.chunks.push( ...dupChunks );
nesteds.diff.components.forEach( ( component, i ) => $.extend( component, diffComponents[ i ] ) );
updateDisplay( [ nesteds.diff ] );
}
}
// If the diff didn't come up with anything, leave it alone.
} );
}
function enhanceDiffLinks() {
// console.log(3, $( changeSelector ) );
// Run through every diff link.
$( changeSelector ).each( function( i, diffLink ) {
// The diff API will only give one at a time...
var href = diffLink.href,
oldid = mw.util.getParamValue( "oldid", href ), //result.old_revid,
pageid = mw.util.getParamValue( "curid", href ),
diffid = mw.util.getParamValue( "diff", href ),
title = mw.util.getParamValue( "title", href ),
// This should be the immediate parent of the summary.
diffLinkParent = listType === 'normal' ?
groupChangesUserPreference ?
diffLink.parentNode.nodeName === 'TD' ?
diffLink.parentNode : // Is nested
diffLink.parentNode // Isn't nested
:
diffLink.parentNode.parentNode :
// Contribs, history
diffLink.parentNode.parentNode.parentNode;
// enhanced = diffLinkParent.nodeName === "TD";
// console.log( 'check', diffLinkParent, diffLink, title, pageid, diffid, oldid, listType, enhanced );
if (
// Only well-formed diff links
diffid && title && oldid &&
// On history pages, don't show earliest diff.
// Don't show earliest diff on page, even if there's a diff
// link, because rvstartid doesn't play nice with "prev".
// TODO: Fix.
( listType !== "history" || diffLinkParent.parentNode.nextElementSibling /* || diffLink.previousSibling.previousSibling */ ) &&
// Check namespace, only show mainspace and Property: NS.
( !title.includes( ":" ) || title.startsWith( "Property:" ) || !isWD ) &&
// Suppress diffs with nested sub-listings.
// No longer necessary? Groups now use ".mw-changeslist-groupdiff" class.
//( !enhanced || !diffLinkParent.parentNode.nextElementSibling || diffLinkParent.parentNode.previousElementSibling )
// Don't process any diff twice
!diffLink.YR_DL_processed
) {
diffLink.YR_DL_processed = true;
// On non-WD wikis, entity links are prefixed with this special page.
// I don't really know why, but let's trim it so the API is okay.
if ( !isWD && title.startsWith( 'Special:EntityPage/' ) ) {
title = title.slice( 'Special:EntityPage/'.length )
}
processDiff( diffLinkParent, oldid, pageid, diffid, title );
}
} );
}
// buildOptionsBox();
var updateDisplay = prepareHidingPreferences();
mw.hook( 'wikipage.content' ).add( enhanceDiffLinks );
});
});
}
mw.loader.using( mw.config.get( 'wgDBname' ) === 'wikidatawiki' ? 'mediawiki.api' : 'mediawiki.ForeignApi', DiffLists );
/*
$.get( api, {
format: "json",
action: "query",
list: "recentchanges",
rcnamespace: 0,
rcprop: "ids"
}, function ( r ) {
//console.log( r );
//var results = r.query.recentchanges; // array
results.forEach( function ( result ) {
var oldid = result.old_revid,
pageid = result.pageid;
*/