Utilisateur:Mwarf/wikipeople.js
Note : après avoir enregistré la page, vous devrez forcer le rechargement complet du cache de votre navigateur pour voir les changements.
Mozilla / Firefox / Konqueror / Safari : maintenez la touche Majuscule (Shift) en cliquant sur le bouton Actualiser (Reload) ou pressez Maj-Ctrl-R (Cmd-R sur Apple Mac) ;
Firefox (sur GNU/Linux) / Chrome / Internet Explorer / Opera : maintenez la touche Ctrl en cliquant sur le bouton Actualiser ou pressez Ctrl-F5./* global mw */
/**
* WikiPeople — names the people who wrote the text you are reading.
*
* The script is wiki-agnostic: it reports its own wiki through wgDBname and lets the
* API decide whether that wiki is served. An unsupported wiki answers 404, the fetch
* rejects, and nothing is rendered — so this same file can ship on every Wikipedia
* while the backend enables wikis one at a time.
*
* Wording and help links come from the reader's own User:<name>/wikipeople-config.json
* on the local wiki, alongside the script itself. While WikiPeople is a personal script
* the reader and the installer are the same person, so no interface-admin rights are
* needed and each wiki gets its own copy. The page is optional: without it the
* built-in defaults apply. Defaults per wiki are published in the repository.
*
* When this becomes a site-wide gadget the page moves to MediaWiki:Wikipeople-config.json
* and only CONFIG_PAGE_SUFFIX and configPage() change.
*
* Two extension points exist so that nobody has to fork this file:
*
* historyIntroPage a wikitext page whose parsed HTML replaces the built-in history
* introduction. Images, galleries, Commons video and templates all
* work, because MediaWiki does the parsing and the sanitising.
* count slots an element of class wikipeople-count or wikipeople-number in that page
* receives this article's contributor count, which the page itself
* cannot hold: it is parsed once and cached for every article.
* mw.hook 'wikipeople.history' and 'wikipeople.summary' fire with the rendered
* element, so arbitrary JavaScript belongs in the reader's own
* common.js rather than in a configuration page.
*/
( function () {
'use strict';
var ARTICLE_SUMMARY_ID = 'wikipeople-summary';
var HISTORY_INTRO_ID = 'wikipeople-history-intro';
var CACHE_VERSION = 'v2';
var TOOLFORGE_API_BASE = 'https://wikipeople.toolforge.org';
var CONFIG_PAGE_SUFFIX = '/wikipeople-config.json';
var REQUEST_TIMEOUT_MS = 8000;
// Deliberately short, and deliberately the same number the API puts in its max-age.
// This cache exists to spare a second request when a reader moves between an article
// and its history, which happens in seconds; holding an answer for a day instead
// meant a policy change on the server was invisible here for a day, because a reader
// with the answer already in hand never asks again and so never finds out it changed.
// Past this window the browser's own cache still answers most requests with a 304.
var CLIENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000;
// Configuration and wikitext are edited by the people reading this page and are not
// affected by anything the API decides, so they keep the longer window.
var CONFIG_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
var CONTENT_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
var PENDING_RETRY_DELAYS_MS = [ 3000, 10000 ];
// The API answers with the metric that produced the names. Only this one measures who
// wrote the text on screen, so only this one earns "written by"; any other metric,
// including one added after this file ships, gets the weaker wording it can support.
var SURVIVING_TEXT_METRIC = 'wikiwho-surviving-alphanumeric-tokens';
// The box is drawn before the answer exists, not after. Nothing reaches this gadget
// in under roughly 280 ms — it opens a fresh connection to another host, so every
// reader pays DNS, TCP and TLS however warm the data is — and inserting the box on
// arrival would push the article down under a reader who has already started reading.
//
// What it says while it waits is a loading label, not a sentence about the article.
// It used to be the count sentence with rolling digits standing in for the number,
// which was a mistake twice over: a reader with reduced motion saw four frozen random
// digits that read as a fact, and the shape it imitated — "written by N people" — is
// not the shape of an answer that has names in it, so it was never the final sentence
// in miniature. A label the reader can dismiss as "not the answer yet" costs nothing
// and cannot be misread.
//
// At PENDING_SETTLE_MS the label gives up and states something true whatever the
// answer turns out to be, because the retry chain can run for thirteen seconds and a
// spinner that long is its own kind of lie.
var PENDING_SETTLE_MS = 2500;
var DEFAULT_CONFIG = {
enabled: true,
showHistoryIntro: true,
editHelpPage: null,
sandboxPage: null,
historyIntroPage: null,
messages: {}
};
/**
* Built-in wording. A wiki overrides any key through the "messages" object of its
* local configuration page, which is also how a new language gets translated
* without touching this script.
*/
var MESSAGES = {
en: {
'wikipeople-summary-prefix': 'Article written by ',
// A different measurement deserves a different claim: these accounts edited
// the page most often, which is not the same as having written what is on it.
'wikipeople-summary-prefix-edits': 'Article most edited by ',
'wikipeople-people': '{{PLURAL:$1|$1 person|$1 people}}',
'wikipeople-others': '{{PLURAL:$1|$1 other person|$1 other people}}',
'wikipeople-at-least': 'at least $1',
// Says what the gadget is doing, not what the article is. Nothing here may
// resemble an answer: it is on screen before one exists.
'wikipeople-pending': 'Analysing contributions…',
'wikipeople-many-people': 'many people',
'wikipeople-user-title': 'View the user page of $1',
'wikipeople-share': '$1 of the currently visible tokens',
'wikipeople-share-edits': '$1 of the edits to this page',
'wikipeople-history-title': 'View the full page history',
'wikipeople-tooltip': 'Main authors of the text according to WikiWho.',
'wikipeople-tooltip-edits': 'Accounts that edited this page most, from its history. The text itself could not be analysed.',
'wikipeople-computed': 'Data computed on $1.',
'wikipeople-history-intro': 'Each line is one version of the article, showing who changed it.',
'wikipeople-history-help': 'To get started, read $1 or practise in $2.',
'wikipeople-history-help-label': 'the editing help',
'wikipeople-history-sandbox-label': 'the sandbox',
'wikipeople-history-edit': 'You can also $1.',
'wikipeople-history-edit-label': 'edit this article directly'
},
fr: {
'wikipeople-summary-prefix': 'Article rédigé par ',
'wikipeople-summary-prefix-edits': 'Article le plus modifié par ',
'wikipeople-people': '{{PLURAL:$1|$1 personne|$1 personnes}}',
'wikipeople-others': '{{PLURAL:$1|$1 autre personne|$1 autres personnes}}',
'wikipeople-at-least': 'au moins $1',
'wikipeople-pending': 'Analyse des contributions…',
'wikipeople-many-people': 'de nombreuses personnes',
'wikipeople-user-title': 'Voir la page utilisateur de $1',
'wikipeople-share': '$1 des tokens actuellement visibles',
'wikipeople-share-edits': '$1 des modifications de la page',
'wikipeople-history-title': 'Voir l’historique complet de l’article',
'wikipeople-tooltip': 'Principaux contributeurs du texte selon WikiWho.',
'wikipeople-tooltip-edits': 'Comptes ayant le plus modifié cette page, d’après son historique. Le texte lui-même n’a pas pu être analysé.',
'wikipeople-computed': 'Données calculées le $1.',
'wikipeople-history-intro': 'Chaque ligne correspond à une version de l’article et indique qui l’a modifiée.',
'wikipeople-history-help': 'Pour commencer, consultez $1 ou entraînez-vous dans $2.',
'wikipeople-history-help-label': 'l’aide à la modification',
'wikipeople-history-sandbox-label': 'le bac à sable',
'wikipeople-history-edit': 'Vous pouvez aussi $1.',
'wikipeople-history-edit-label': 'modifier directement cet article'
}
};
var config = mw.config.get( [
'wgAction',
'wgArticleId',
'wgCurRevisionId',
'wgDBname',
'wgDiffNewId',
'wgDiffOldId',
'wgNamespaceNumber',
'wgPageName',
'wgRevisionId',
'wgUserLanguage',
'wgUserName'
] );
var numberFormatter;
var percentageFormatter;
var dateFormatter;
var listFormatter;
if ( config.wgNamespaceNumber !== 0 || !config.wgArticleId || !config.wgDBname ) {
return;
}
mw.loader.using( [ 'mediawiki.util', 'mediawiki.Title', 'mediawiki.jqueryMsg' ] )
.then( loadWikiConfig )
.then( function ( wikiConfig ) {
if ( wikiConfig.enabled === false ) {
return;
}
installMessages( wikiConfig );
installFormatters();
if ( config.wgAction === 'history' ) {
if ( wikiConfig.showHistoryIntro !== false ) {
return addHistoryIntroduction( wikiConfig );
}
return;
}
if (
config.wgAction === 'view' &&
!config.wgDiffOldId &&
!config.wgDiffNewId &&
config.wgRevisionId === config.wgCurRevisionId
) {
return addArticleSummary();
}
} )
.catch( function ( error ) {
mw.log.warn( 'WikiPeople: initialisation failed', error );
} );
/* -------------------------------------------------------------- configuration */
/**
* Title of the configuration page for this reader on this wiki, or null when there
* is nobody to attribute it to. Namespace 2 resolves to the wiki's own localised
* user-namespace name, so this works unchanged on every language edition.
*/
function configPage() {
if ( !config.wgUserName ) {
return null;
}
return new mw.Title( config.wgUserName + CONFIG_PAGE_SUFFIX, 2 ).getPrefixedDb();
}
/**
* Read the configuration page. A missing page is the normal case for someone who has
* not customised anything, so a 404 resolves to the defaults rather than rejecting.
*/
async function loadWikiConfig() {
var page = configPage();
var cacheKey;
var cached;
var url;
var response;
var parsed;
if ( !page ) {
return Object.assign( {}, DEFAULT_CONFIG );
}
// The page is per user as well as per wiki, so both belong in the key: a shared
// browser must not serve one account's configuration to the next.
cacheKey = 'wikipeople:config:' + CACHE_VERSION + ':' + config.wgDBname + ':' + page;
cached = readCache( cacheKey, CONFIG_CACHE_MAX_AGE_MS );
if ( cached ) {
return Object.assign( {}, DEFAULT_CONFIG, cached );
}
url = mw.util.wikiScript( 'index' ) +
'?title=' + encodeURIComponent( page ) +
'&action=raw&ctype=application/json';
try {
response = await fetch( url, {
headers: { Accept: 'application/json' },
credentials: 'omit'
} );
parsed = response.ok ? await response.json() : {};
} catch ( error ) {
parsed = {};
}
if ( !parsed || typeof parsed !== 'object' || Array.isArray( parsed ) ) {
parsed = {};
}
writeCache( cacheKey, parsed );
return Object.assign( {}, DEFAULT_CONFIG, parsed );
}
/* ------------------------------------------------------------- custom content */
/**
* Titles to try for a reader's own rich content, most specific first.
*
* A wikitext page is written in one language, so translations live on language
* subpages: /fr-ca, then /fr, then the base title. That keeps one reviewable page
* per language instead of one page in whichever language its author happened to
* speak, which is the problem the flat "messages" object has.
*/
function contentCandidates( base ) {
var language = config.wgUserLanguage || 'en';
var candidates = [ base + '/' + language ];
if ( language.indexOf( '-' ) !== -1 ) {
candidates.push( base + '/' + language.split( '-' )[ 0 ] );
}
candidates.push( base );
return candidates;
}
/**
* Parsed HTML for one title, or null when it does not exist.
*
* Anonymous so the response stays CDN-cacheable, and asks for a short server-side
* cache: an introduction changes rarely but is read on every history view.
*/
async function fetchParsedPage( title ) {
var url = mw.util.wikiScript( 'api' ) +
'?action=parse&format=json&formatversion=2&prop=text' +
'&redirects=1&disablelimitreport=1&disableeditsection=1' +
'&smaxage=300&maxage=300' +
'&page=' + encodeURIComponent( title );
var response;
var data;
try {
response = await fetch( url, {
headers: { Accept: 'application/json' },
credentials: 'omit'
} );
if ( !response.ok ) {
return null;
}
data = await response.json();
} catch ( error ) {
return null;
}
// A missing page reports an error code rather than an HTTP status, and is the
// normal case for a reader who has not written one.
if ( !data || !data.parse || typeof data.parse.text !== 'string' ) {
return null;
}
return data.parse.text;
}
/**
* Rich introduction for this reader, already parsed by MediaWiki, or null.
*
* Wikitext buys images, galleries, Commons video and templates for nothing, and the
* parser sanitises it, so this script never has to build markup from a string or
* trust the page to be well-behaved.
*/
async function loadCustomContent( title ) {
var cacheKey;
var cached;
var candidates;
var index;
var html = null;
if ( typeof title !== 'string' || !title ) {
return null;
}
cacheKey = 'wikipeople:content:' + CACHE_VERSION + ':' + config.wgDBname + ':' +
title + ':' + ( config.wgUserLanguage || 'en' );
cached = readCache( cacheKey, CONTENT_CACHE_MAX_AGE_MS );
if ( cached ) {
return cached.html;
}
candidates = contentCandidates( title );
for ( index = 0; index < candidates.length && !html; index++ ) {
html = await fetchParsedPage( candidates[ index ] );
}
// The absence of a page is cached too. Otherwise a configured but unwritten
// title costs three failed lookups on every single history view.
writeCache( cacheKey, { html: html } );
return html;
}
/**
* Turn parser output into nodes.
*
* DOMParser builds an inert document, so nothing loads or runs while the fragment is
* assembled. Wikitext cannot produce a script element in the first place; removing
* any that appear keeps that true of this function on its own, without having to
* reason about the whole parser pipeline to review it.
*/
function renderCustomContent( html ) {
var parsed = new DOMParser().parseFromString( html, 'text/html' );
var container = document.createElement( 'div' );
parsed.querySelectorAll( 'script' ).forEach( function ( node ) {
node.remove();
} );
// A note box is not worth blocking a page render for: media loads when reached,
// and video never starts on its own in something the reader did not ask to play.
parsed.querySelectorAll( 'img' ).forEach( function ( image ) {
image.setAttribute( 'loading', 'lazy' );
} );
parsed.querySelectorAll( 'video' ).forEach( function ( video ) {
video.removeAttribute( 'autoplay' );
video.setAttribute( 'preload', 'none' );
} );
if ( parsed.querySelector( 'video, .mw-tmh-player' ) ) {
// Commons video falls back to a bare player without TimedMediaHandler, and
// the module is absent on some wikis, so failing to load it is not an error.
mw.loader.using( 'ext.tmh.player' ).catch( function () {} );
}
container.className = 'wikipeople-custom';
container.append.apply(
container,
Array.prototype.slice.call( parsed.body.childNodes )
);
return container;
}
function installMessages( wikiConfig ) {
var language = config.wgUserLanguage || 'en';
var base = language.split( '-' )[ 0 ];
var table = Object.assign(
{},
MESSAGES.en,
MESSAGES[ base ] || {},
MESSAGES[ language ] || {}
);
if ( wikiConfig.messages && typeof wikiConfig.messages === 'object' ) {
Object.keys( wikiConfig.messages ).forEach( function ( key ) {
if ( typeof wikiConfig.messages[ key ] === 'string' ) {
table[ key ] = wikiConfig.messages[ key ];
}
} );
}
mw.messages.set( table );
}
/**
* Not every MediaWiki language code is a valid BCP 47 tag, so each formatter falls
* back through the base language to English rather than throwing.
*/
function safeFormatter( build ) {
var language = config.wgUserLanguage || 'en';
var candidates = [ language, language.split( '-' )[ 0 ], 'en' ];
var index;
for ( index = 0; index < candidates.length; index++ ) {
try {
return build( candidates[ index ] );
} catch ( error ) {
continue;
}
}
return null;
}
function installFormatters() {
numberFormatter = safeFormatter( function ( locale ) {
return new Intl.NumberFormat( locale );
} );
percentageFormatter = safeFormatter( function ( locale ) {
return new Intl.NumberFormat( locale, {
maximumFractionDigits: 1,
style: 'percent'
} );
} );
dateFormatter = safeFormatter( function ( locale ) {
return new Intl.DateTimeFormat( locale, {
day: 'numeric',
month: 'long',
year: 'numeric'
} );
} );
listFormatter = typeof Intl !== 'undefined' && Intl.ListFormat ?
safeFormatter( function ( locale ) {
return new Intl.ListFormat( locale, { style: 'long', type: 'conjunction' } );
} ) :
null;
}
function formatNumber( value ) {
return numberFormatter ? numberFormatter.format( value ) : String( value );
}
/* ------------------------------------------------------------------ rendering */
function insertBelowSubtitle( element ) {
var siteSub = document.getElementById( 'siteSub' );
var vectorSlot = document.querySelector( '.vector-body-before-content' );
var bodyContent = document.getElementById( 'bodyContent' );
if ( siteSub ) {
siteSub.after( element );
return true;
}
if ( vectorSlot ) {
vectorSlot.prepend( element );
return true;
}
if ( bodyContent ) {
bodyContent.prepend( element );
return true;
}
return false;
}
/**
* Replace $1, $2… in a message with DOM nodes, so a sentence can contain links
* without ever building HTML from a string.
*/
function appendMessageWithNodes( parent, messageKey, nodes ) {
var text = mw.message( messageKey ).text();
var pattern = /\$(\d+)/g;
var lastIndex = 0;
var match;
while ( ( match = pattern.exec( text ) ) !== null ) {
if ( match.index > lastIndex ) {
parent.append( document.createTextNode( text.slice( lastIndex, match.index ) ) );
}
parent.append( nodes[ Number( match[ 1 ] ) - 1 ] || document.createTextNode( '' ) );
lastIndex = match.index + match[ 0 ].length;
}
if ( lastIndex < text.length ) {
parent.append( document.createTextNode( text.slice( lastIndex ) ) );
}
}
async function addHistoryIntroduction( wikiConfig ) {
var box;
var line;
var custom;
if ( document.getElementById( HISTORY_INTRO_ID ) ) {
return;
}
box = document.createElement( 'div' );
box.id = HISTORY_INTRO_ID;
box.className = 'wikipeople wikipeople--history';
box.setAttribute( 'role', 'note' );
custom = await loadCustomContent( wikiConfig.historyIntroPage );
if ( custom ) {
box.append( renderCustomContent( custom ) );
} else {
line = document.createElement( 'p' );
line.textContent = mw.message( 'wikipeople-history-intro' ).text();
box.append( line );
// The help and sandbox pages have no cross-wiki names, so this sentence only
// appears where the local configuration page supplies both titles.
if ( wikiConfig.editHelpPage && wikiConfig.sandboxPage ) {
line = document.createElement( 'p' );
appendMessageWithNodes( line, 'wikipeople-history-help', [
createWikiLink(
wikiConfig.editHelpPage,
mw.message( 'wikipeople-history-help-label' ).text()
),
createWikiLink(
wikiConfig.sandboxPage,
mw.message( 'wikipeople-history-sandbox-label' ).text()
)
] );
box.append( line );
}
}
// Always built here, never in wikitext: a page parsed on its own has no idea
// which article the reader is looking at, so {{FULLPAGENAME}} would name the
// introduction itself and the link would offer to edit the wrong page.
line = document.createElement( 'p' );
appendMessageWithNodes( line, 'wikipeople-history-edit', [ createEditLink() ] );
box.append( line );
insertBelowSubtitle( box );
mw.hook( 'wikipeople.history' ).fire( box, wikiConfig );
// After the box is in the page, never before. The introduction is the reason the
// reader is looking, and it must not wait on an API round trip to appear.
return fillContributorCount( box );
}
/**
* Put this article's contributor count into any slot the custom content declared.
*
* A wikitext page is parsed on its own and cached for every article, so it cannot
* contain a per-article number. It writes an element of a known class instead, keeps
* plain wording inside it as a fallback, and this replaces that text once the API
* answers. A page that asks for no count triggers no request at all, which is what
* keeps history views free for everyone who does not use this.
*/
async function fillContributorCount( box ) {
var phrases = box.querySelectorAll( '.wikipeople-count' );
var numbers = box.querySelectorAll( '.wikipeople-number' );
var data;
var phrase;
if ( !phrases.length && !numbers.length ) {
return;
}
try {
// No retry here, unlike an article view: a result that is still being computed
// should leave the reader's own wording alone rather than rewrite the box under
// them ten seconds after they started reading it.
data = await contributionData( [] );
} catch ( error ) {
mw.log.warn( 'WikiPeople: contributor count unavailable', error );
return;
}
if ( !data || data.humanCount < 1 ) {
return;
}
phrase = mw.message( 'wikipeople-people', formatNumber( data.humanCount ) ).text();
if ( data.limited ) {
phrase = mw.message( 'wikipeople-at-least', phrase ).text();
}
phrases.forEach( function ( slot ) {
slot.textContent = phrase;
} );
numbers.forEach( function ( slot ) {
slot.textContent = formatNumber( data.humanCount );
} );
}
/**
* What the box should say right now.
*
* 'loading' the gadget is working; the box says so and claims nothing else.
* 'vague' wording that holds whatever the answer turns out to be.
* 'final' the real sentence.
*
* There is no state for an empty page: the box exists from the first frame, so that
* the sentence lands in space already reserved for it rather than shoving the
* article aside three hundred milliseconds in.
*
* Separated from the DOM so the whole timing policy is five readable lines. Reduced
* motion is not a state here any more: there is nothing left that moves.
*/
function countDisplayState( elapsedMs, data ) {
if ( data ) {
return 'final';
}
if ( elapsedMs < PENDING_SETTLE_MS ) {
return 'loading';
}
return 'vague';
}
/**
* A box that says the gadget is working, and nothing about the article.
*
* `aria-busy` marks it as in progress; the label itself is stable and meaningful, so
* unlike the digits it replaced there is no reason to hide it from a screen reader.
*/
function buildPendingSummary() {
var box = document.createElement( 'div' );
box.id = ARTICLE_SUMMARY_ID;
box.className = 'wikipeople wikipeople--article wikipeople-pending';
box.setAttribute( 'role', 'note' );
box.setAttribute( 'aria-busy', 'true' );
box.textContent = mw.message( 'wikipeople-pending' ).text();
return box;
}
/**
* Stop promising an answer and say something that stays true if none comes.
*
* This is a refinement, not a correction: vague then precise, never wrong then right.
* That is what separates it from showing a number the API would later contradict.
*/
function settlePendingSummary( box ) {
box.textContent = mw.message( 'wikipeople-summary-prefix' ).text() +
mw.message( 'wikipeople-many-people' ).text() + '.';
box.classList.remove( 'wikipeople-pending' );
box.removeAttribute( 'aria-busy' );
}
/**
* Hold the box until the request resolves, one way or another.
*
* The box goes in on the first pass, before the first await, so it is part of the
* page from the moment the gadget runs. Exits as soon as `outcome.done` is set.
*/
async function runCountPlaceholder( startedAt, outcome ) {
var box = buildPendingSummary();
// An unrecognised skin offers nowhere to put it, and there is no point holding
// space in a page that will never receive the sentence.
if ( !insertBelowSubtitle( box ) ) {
return;
}
while ( !outcome.done ) {
if ( countDisplayState( Date.now() - startedAt, null ) === 'loading' ) {
// Nothing redraws while it waits, so this is one timer rather than
// thirty. 'loading' only occurs below the threshold, so the delay stays
// positive and the loop cannot spin.
await wait( PENDING_SETTLE_MS - ( Date.now() - startedAt ) );
continue;
}
// Settled. Let the awaited request replace the wording if it ever arrives.
settlePendingSummary( box );
return;
}
}
function removeArticleSummary() {
var existing = document.getElementById( ARTICLE_SUMMARY_ID );
if ( existing ) {
existing.remove();
}
}
/**
* Render the attribution sentence, filling it in as the answer arrives.
*
* The placeholder appears only when the wait is real: a cached answer skips it and
* renders straight away. A page the API cannot serve — an unsupported wiki, a network
* failure — must leave no trace, which is why an error is told apart from a result
* that is merely still being computed.
*/
async function addArticleSummary() {
var startedAt = Date.now();
var outcome = { done: false, data: null, failed: false };
var existing;
var pending;
var summary;
if ( document.getElementById( ARTICLE_SUMMARY_ID ) ) {
return;
}
// Read the session cache before drawing anything. A cached answer needs no
// network, so there is no wait for a placeholder to explain — and drawing one
// anyway would flash "analysing" on every revisit, which is the common path for
// anyone moving between an article and its history.
outcome.data = readCache( getCacheKey(), CLIENT_CACHE_MAX_AGE_MS );
if ( !outcome.data ) {
pending = contributionData( PENDING_RETRY_DELAYS_MS ).then( function ( value ) {
outcome.data = value;
}, function ( error ) {
mw.log.warn( 'WikiPeople: attribution unavailable', error );
outcome.failed = true;
} ).then( function () {
outcome.done = true;
} );
await Promise.all( [ pending, runCountPlaceholder( startedAt, outcome ) ] );
}
// An error is not a slow answer: an unsupported wiki must not be left claiming
// that many people wrote the article.
if ( outcome.failed ) {
removeArticleSummary();
return;
}
// Still pending after every retry. The settled wording is the final answer.
if ( !outcome.data ) {
return;
}
summary = buildArticleSummary( outcome.data );
if ( !summary ) {
removeArticleSummary();
return;
}
existing = document.getElementById( ARTICLE_SUMMARY_ID );
if ( existing ) {
existing.replaceWith( summary );
} else {
insertBelowSubtitle( summary );
}
mw.hook( 'wikipeople.summary' ).fire( summary, outcome.data );
}
/**
* Attribution for this page, from the session cache when it is there.
*
* The cache key is the page, not the view, so a reader who looks at an article and
* then opens its history pays for one request rather than two. How long to wait on a
* result that is still being computed is the caller's decision.
*/
async function contributionData( retryDelaysMs ) {
var cacheKey = getCacheKey();
var cached = readCache( cacheKey, CLIENT_CACHE_MAX_AGE_MS );
var data;
if ( cached ) {
return cached;
}
data = await loadContributionData( retryDelaysMs );
if ( data ) {
writeCache( cacheKey, data );
}
return data;
}
async function loadContributionData( retryDelaysMs ) {
var url = TOOLFORGE_API_BASE + '/v2/' +
encodeURIComponent( config.wgDBname ) + '/pages/' +
encodeURIComponent( config.wgArticleId ) +
'?revision_id=' + encodeURIComponent( config.wgCurRevisionId );
var delays = retryDelaysMs || PENDING_RETRY_DELAYS_MS;
var attempt;
var data;
for ( attempt = 0; attempt <= delays.length; attempt++ ) {
data = await fetchJson( url, {
credentials: 'omit',
referrerPolicy: 'no-referrer'
} );
if ( data.status === 'ready' ) {
if (
typeof data.distinct_contributors !== 'number' ||
!Array.isArray( data.contributors )
) {
throw new Error( 'Invalid attribution response.' );
}
return normalizeContributionData( data );
}
if ( data.status !== 'pending' ) {
throw new Error( 'Unknown attribution state.' );
}
if ( attempt < delays.length ) {
await wait( delays[ attempt ] );
}
}
return null;
}
function normalizeContributionData( data ) {
return {
computedAt: data.computed_at,
humanCount: data.distinct_contributors,
limited: Boolean( data.count_limited ),
// Which rung of the ladder answered. The names alone do not say whether they
// wrote the text or merely edited it most, and the sentence has to.
wroteTheText: data.metric === SURVIVING_TEXT_METRIC,
topEditors: data.contributors.slice( 0, 3 ).map( function ( editor ) {
return {
share: Number( editor.share ),
username: editor.username
};
} )
};
}
function wait( delayMs ) {
return new Promise( function ( resolve ) {
window.setTimeout( resolve, delayMs );
} );
}
async function fetchJson( url, options ) {
var controller = new AbortController();
var timeout = window.setTimeout( function () {
controller.abort();
}, REQUEST_TIMEOUT_MS );
var response;
try {
response = await fetch( url, Object.assign( {
headers: {
Accept: 'application/json'
},
signal: controller.signal
}, options ) );
if ( !response.ok ) {
throw new Error( 'HTTP error ' + response.status );
}
return await response.json();
} finally {
window.clearTimeout( timeout );
}
}
function buildArticleSummary( data ) {
var box = document.createElement( 'div' );
var topEditors = data.topEditors;
var otherCount = Math.max( 0, data.humanCount - topEditors.length );
var computedDate = new Date( data.computedAt );
// Only a named ranking can be weaker than "written by". With no names the
// sentence is about the count alone, which the edit history and the token
// analysis agree on, so it keeps the ordinary wording.
var namedByEditCount = topEditors.length > 0 && !data.wroteTheText;
var tooltip = [];
var nodes;
if ( data.humanCount < 1 ) {
return null;
}
box.id = ARTICLE_SUMMARY_ID;
box.className = 'wikipeople wikipeople--article';
box.setAttribute( 'role', 'note' );
// Both tooltips describe a ranking, so neither belongs on a box that shows none.
// A page WikiWho refused can still reach this branch, and saying its names come
// from WikiWho would then credit a service that never saw the article.
if ( topEditors.length ) {
tooltip.push( mw.message(
namedByEditCount ? 'wikipeople-tooltip-edits' : 'wikipeople-tooltip'
).text() );
}
if ( !Number.isNaN( computedDate.getTime() ) && dateFormatter ) {
tooltip.push( mw.message(
'wikipeople-computed',
dateFormatter.format( computedDate )
).text() );
}
box.title = tooltip.join( ' ' );
box.append( document.createTextNode( mw.message(
namedByEditCount ? 'wikipeople-summary-prefix-edits' : 'wikipeople-summary-prefix'
).text() ) );
if ( topEditors.length ) {
nodes = topEditors.map( function ( editor ) {
return createEditorLink( editor, namedByEditCount );
} );
if ( otherCount > 0 ) {
nodes.push( createHistoryCountLink( otherCount, data.limited, true ) );
}
} else {
nodes = [ createHistoryCountLink( data.humanCount, data.limited, false ) ];
}
appendList( box, nodes );
box.append( document.createTextNode( '.' ) );
return box;
}
/**
* Join the links with the conjunction of the reader's language. Intl.ListFormat
* knows that English wants "A, B and C" while other languages differ, so the
* separator is never hard-coded. Placeholders carry the node index through
* formatToParts, which keeps real DOM elements in the sentence.
*/
function appendList( parent, nodes ) {
var placeholders;
var parts;
if ( nodes.length === 1 ) {
parent.append( nodes[ 0 ] );
return;
}
if ( !listFormatter ) {
nodes.forEach( function ( node, index ) {
if ( index > 0 ) {
parent.append( document.createTextNode( ', ' ) );
}
parent.append( node );
} );
return;
}
placeholders = nodes.map( function ( _node, index ) {
return '\u0000' + index;
} );
parts = listFormatter.formatToParts( placeholders );
parts.forEach( function ( part ) {
if ( part.type === 'element' ) {
parent.append( nodes[ Number( part.value.slice( 1 ) ) ] );
} else {
parent.append( document.createTextNode( part.value ) );
}
} );
}
function createEditorLink( editor, byEditCount ) {
var link = document.createElement( 'a' );
var name = editor.username.replace( /_/g, ' ' );
var title = new mw.Title( editor.username, 2 );
link.href = title.getUrl();
link.textContent = name;
link.title = mw.message( 'wikipeople-user-title', name ).text();
if ( Number.isFinite( editor.share ) && percentageFormatter ) {
// The share is a share of whatever was ranked: of the visible tokens, or of
// the page's edits. Naming the wrong one turns a true percentage into a
// false statement.
link.title += ' — ' + mw.message(
byEditCount ? 'wikipeople-share-edits' : 'wikipeople-share',
percentageFormatter.format( editor.share )
).text();
}
return link;
}
function createHistoryCountLink( count, limited, isRemainder ) {
var link = document.createElement( 'a' );
var label = mw.message(
isRemainder ? 'wikipeople-others' : 'wikipeople-people',
formatNumber( count )
).text();
if ( limited ) {
label = mw.message( 'wikipeople-at-least', label ).text();
}
link.href = mw.util.getUrl( config.wgPageName, { action: 'history' } );
link.textContent = label;
link.title = mw.message( 'wikipeople-history-title' ).text();
return link;
}
function createWikiLink( title, label ) {
var link = document.createElement( 'a' );
link.href = mw.util.getUrl( title );
link.textContent = label;
return link;
}
function createEditLink() {
var link = document.createElement( 'a' );
link.href = mw.util.getUrl( config.wgPageName, { veaction: 'edit' } );
link.textContent = mw.message( 'wikipeople-history-edit-label' ).text();
return link;
}
/* -------------------------------------------------------------------- caching */
function getCacheKey() {
return 'wikipeople:' + CACHE_VERSION + ':' +
config.wgDBname + ':' +
config.wgArticleId;
}
function readCache( key, maxAgeMs ) {
var raw;
var cached;
try {
raw = window.sessionStorage.getItem( key );
cached = raw ? JSON.parse( raw ) : null;
if (
!cached ||
typeof cached.storedAt !== 'number' ||
Date.now() - cached.storedAt > maxAgeMs
) {
window.sessionStorage.removeItem( key );
return null;
}
return cached.data || null;
} catch ( error ) {
return null;
}
}
function writeCache( key, data ) {
try {
window.sessionStorage.setItem( key, JSON.stringify( {
data: data,
storedAt: Date.now()
} ) );
} catch ( error ) {
// The gadget stays functional when storage is disabled or full.
}
}
}() );