MediaWiki:Common.js: Difference between revisions
MediaWiki interface page
More actions
Palindromayd (talk | contribs) No edit summary |
Palindromayd (talk | contribs) No edit summary |
||
| Line 2: | Line 2: | ||
'use strict'; | 'use strict'; | ||
var GAR_NS = 3000; // NS_GAR | |||
/* | /* ========================================================== | ||
Safety helpers | |||
========================================================== */ | |||
// Neutralise wiki characters so a typed value | // Neutralise wiki + HTML characters so a typed value cannot inject | ||
// templates, links, parameters, or parser/HTML tags. | |||
function escapeWikitext( str ) { | function escapeWikitext( str ) { | ||
return String( str ) | return String( str ) | ||
| Line 48: | Line 16: | ||
.replace( /\[/g, '[' ) | .replace( /\[/g, '[' ) | ||
.replace( /\]/g, ']' ) | .replace( /\]/g, ']' ) | ||
.replace( /\|/g, '|' ); | .replace( /\|/g, '|' ) | ||
.replace( /</g, '<' ) | |||
.replace( />/g, '>' ); | |||
} | } | ||
// Escape $ so | // Escape $ so a value is safe inside a JS String.replace() replacement. | ||
function safeReplacement( str ) { | function safeReplacement( str ) { | ||
return str.replace( /\$/g, '$$$$' ); | return str.replace( /\$/g, '$$$$' ); | ||
} | |||
// Reject a typed page name that contains title-illegal characters or a | |||
// slash (which would silently create an off-structure subpage). Returns | |||
// a cleaned name, or null if it must be refused. | |||
function sanitizeName( name ) { | |||
var n = String( name ).replace( /\s+/g, ' ' ).trim(); | |||
if ( !n ) { | |||
return null; | |||
} | |||
if ( /[#<>\[\]{}|/]/.test( n ) ) { | |||
return null; | |||
} | |||
return n; | |||
} | |||
// Filenames: same idea, but a filename legitimately may contain dots. | |||
function sanitizeFilename( name ) { | |||
var n = String( name ).replace( /\s+/g, ' ' ).trim(); | |||
if ( !n ) { | |||
return null; | |||
} | |||
if ( /[#<>\[\]{}|]/.test( n ) ) { | |||
return null; | |||
} | |||
return n; | |||
} | |||
// Strip a leading "Namespace:" from a title, leaving the bare path. | |||
// (Titles come back from the API with spaces, e.g. "GAR:YouTube/A".) | |||
function stripNs( title ) { | |||
var t = String( title ); | |||
var idx = t.indexOf( ':' ); | |||
return idx > -1 ? t.slice( idx + 1 ) : t; | |||
} | } | ||
| Line 68: | Line 72: | ||
} | } | ||
/* ---------- Instant-create core | // Zero-pad a number to 7 digits (Case-0000001). | ||
function pad7( n ) { | |||
var s = String( n ); | |||
while ( s.length < 7 ) { | |||
s = '0' + s; | |||
} | |||
return s; | |||
} | |||
// Mimic MediaWiki's preload transform on raw template text: | |||
// <onlyinclude> wins, <noinclude> removed, <includeonly> unwrapped. | |||
// Lets preload templates be wrapped in <includeonly> (so the template | |||
// page itself renders empty and joins no category) while the real page | |||
// still receives clean wikitext. | |||
function stripPreload( text ) { | |||
var only = text.match( /<onlyinclude>([\s\S]*?)<\/onlyinclude>/gi ); | |||
if ( only ) { | |||
text = only.map( function ( s ) { | |||
return s.replace( /<\/?onlyinclude>/gi, '' ); | |||
} ).join( '' ); | |||
} | |||
text = text.replace( /<noinclude>[\s\S]*?<\/noinclude>/gi, '' ); | |||
text = text.replace( /<\/?includeonly>/gi, '' ); | |||
return text.replace( /^\n+/, '' ); | |||
} | |||
function markDone( $el ) { | |||
if ( $el.data( 'garDone' ) ) { | |||
return false; | |||
} | |||
$el.data( 'garDone', true ); | |||
return true; | |||
} | |||
/* ========================================================== | |||
Live auto-updating grid. Lists real GAR pages by their | |||
subpage structure (not categories), so nothing has to be | |||
"registered" and template pages can never appear as tiles. | |||
Two modes, set on the div: | |||
data-listmode="platforms" -> top-level GAR pages (no "/"), | |||
excluding the system pages. | |||
data-listmode="children" -> the direct sub-pages of | |||
data-parent (its immediate | |||
children only, not grandchildren). | |||
========================================================== */ | |||
var GAR_SYSTEM_PAGES = { 'Main': true, 'Offline': true }; | |||
function renderAutoGrid( $container ) { | |||
if ( !markDone( $container ) ) { | |||
return; | |||
} | |||
var mode = $container.data( 'listmode' ) || 'children'; | |||
$container.text( 'Loading...' ); | |||
mw.loader.using( 'mediawiki.api' ).then( function () { | |||
var api = new mw.Api(); | |||
if ( mode === 'platforms' ) { | |||
return api.get( { | |||
action: 'query', | |||
list: 'allpages', | |||
apnamespace: GAR_NS, | |||
aplimit: 'max', | |||
formatversion: 2, | |||
format: 'json' | |||
} ).then( function ( data ) { | |||
var pages = ( data.query && data.query.allpages ) || []; | |||
var items = []; | |||
pages.forEach( function ( p ) { | |||
var path = stripNs( p.title ); | |||
if ( path.indexOf( '/' ) === -1 && !GAR_SYSTEM_PAGES[ path ] ) { | |||
items.push( { title: p.title, label: path } ); | |||
} | |||
} ); | |||
return items; | |||
} ); | |||
} | |||
// mode === 'children' | |||
var parent = $container.data( 'parent' ) || mw.config.get( 'wgPageName' ); | |||
var parentPath = stripNs( String( parent ).replace( /_/g, ' ' ) ); | |||
var prefix = parentPath + '/'; | |||
return api.get( { | |||
action: 'query', | |||
list: 'allpages', | |||
apnamespace: GAR_NS, | |||
apprefix: prefix, | |||
aplimit: 'max', | |||
formatversion: 2, | |||
format: 'json' | |||
} ).then( function ( data ) { | |||
var pages = ( data.query && data.query.allpages ) || []; | |||
var items = []; | |||
pages.forEach( function ( p ) { | |||
var path = stripNs( p.title ); | |||
if ( path.indexOf( prefix ) === 0 ) { | |||
var rest = path.slice( prefix.length ); | |||
if ( rest && rest.indexOf( '/' ) === -1 ) { | |||
items.push( { title: p.title, label: rest } ); | |||
} | |||
} | |||
} ); | |||
return items; | |||
} ); | |||
} ).then( function ( items ) { | |||
$container.empty(); | |||
if ( !items || items.length === 0 ) { | |||
$container.append( $( '<p>' ).text( 'Nothing has been added here yet.' ) ); | |||
return; | |||
} | |||
items.sort( function ( a, b ) { | |||
return a.label.localeCompare( b.label ); | |||
} ); | |||
items.forEach( function ( it ) { | |||
$container.append( | |||
$( '<a>' ).attr( 'href', mw.util.getUrl( it.title ) ).text( it.label ) | |||
); | |||
} ); | |||
}, function () { | |||
$container.text( 'Could not load this list right now.' ); | |||
} ); | |||
} | |||
/* ========================================================== | |||
Instant-create core | |||
========================================================== */ | |||
function fallbackToManualEditor( newTitle, preloadTitle, $status, reason ) { | function fallbackToManualEditor( newTitle, preloadTitle, $status, reason ) { | ||
console.error( 'GAR instant-create fell back to manual editor. Reason: ' + reason + '. Title: ' + newTitle ); | console.error( 'GAR instant-create fell back to manual editor. Reason: ' + reason + '. Title: ' + newTitle ); | ||
| Line 133: | Line 263: | ||
return; | return; | ||
} | } | ||
var finalText = applyExtraFields( rawText, fields ); | var finalText = applyExtraFields( stripPreload( rawText ), fields ); | ||
api.postWithToken( 'csrf', { | api.postWithToken( 'csrf', { | ||
action: 'edit', | action: 'edit', | ||
| Line 147: | Line 277: | ||
fallbackToManualEditor( newTitle, preloadTitle, $status, 'edit call did not report Success' ); | fallbackToManualEditor( newTitle, preloadTitle, $status, 'edit call did not report Success' ); | ||
} | } | ||
}, function ( code | }, function ( code ) { | ||
if ( code === 'articleexists' ) { | |||
window.location.href = mw.util.getUrl( newTitle ); | |||
return; | |||
} | |||
fallbackToManualEditor( newTitle, preloadTitle, $status, 'edit call failed: ' + code ); | fallbackToManualEditor( newTitle, preloadTitle, $status, 'edit call failed: ' + code ); | ||
} ); | } ); | ||
}, function ( code | }, function ( code ) { | ||
fallbackToManualEditor( newTitle, preloadTitle, $status, 'template fetch failed: ' + code ); | fallbackToManualEditor( newTitle, preloadTitle, $status, 'template fetch failed: ' + code ); | ||
} ); | } ); | ||
}, function ( code | }, function ( code ) { | ||
fallbackToManualEditor( newTitle, preloadTitle, $status, 'existence check failed: ' + code ); | fallbackToManualEditor( newTitle, preloadTitle, $status, 'existence check failed: ' + code ); | ||
} ); | } ); | ||
| Line 159: | Line 293: | ||
} | } | ||
/* | /* ========================================================== | ||
Typed-name forms (platforms, countries, cities, people) | |||
========================================================== */ | |||
function buildForm( $container ) { | function buildForm( $container ) { | ||
if ( !markDone( $container ) ) { | |||
return; | |||
} | |||
var prefix = $container.data( 'prefix' ); | var prefix = $container.data( 'prefix' ); | ||
var preloadTitle = $container.data( 'preload' ); | var preloadTitle = $container.data( 'preload' ); | ||
| Line 198: | Line 337: | ||
function submit() { | function submit() { | ||
var name = | var name = sanitizeName( $input.val() ); | ||
if ( !name ) { | if ( !name ) { | ||
mw.notify( 'Please use a plain name without the characters / # < > [ ] { } |', { type: 'error', tag: 'gar-name' } ); | |||
return; | return; | ||
} | } | ||
if ( requiredLetter && firstLetter( name ) !== requiredLetter ) { | if ( requiredLetter && firstLetter( name ) !== requiredLetter ) { | ||
$status.text( '' ); | $status.text( '' ); | ||
| Line 213: | Line 351: | ||
return; | return; | ||
} | } | ||
var fields = {}; | var fields = {}; | ||
for ( var key in extraInputs ) { | for ( var key in extraInputs ) { | ||
| Line 238: | Line 375: | ||
} | } | ||
/* -- | /* ========================================================== | ||
function | "Create case" button: auto-numbers Case-0000001, 0000002 ... | ||
$ | ========================================================== */ | ||
function buildCreateCase( $container ) { | |||
var | if ( !markDone( $container ) ) { | ||
return; | |||
} | |||
$ | var person = $container.data( 'person' ); // e.g. GAR:Aaa/A/Australia/A/Adelaide/Stevie | ||
$ | var preloadTitle = $container.data( 'preload' ); // Template:Preload/Case | ||
var extraFieldsRaw = $container.data( 'extrafields' ) || ''; | |||
var $wrapper = $( '<div>' ); | |||
var extraInputs = {}; | |||
if ( extraFieldsRaw ) { | |||
extraFieldsRaw.split( ';' ).forEach( function ( pair ) { | |||
var parts = pair.split( ':' ); | |||
var key = $.trim( parts[ 0 ] ); | |||
var label = $.trim( parts[ 1 ] || key ); | |||
var $extra; | |||
if ( key === 'description' ) { | |||
$extra = $( '<textarea>' ).attr( 'placeholder', label ).attr( 'rows', 3 ).css( 'width', '100%' ).css( 'margin-bottom', '6px' ); | |||
} else { | |||
$extra = $( '<input>' ).attr( 'type', 'text' ).attr( 'placeholder', label ).css( 'width', '100%' ).css( 'margin-bottom', '6px' ); | |||
} | |||
extraInputs[ key ] = $extra; | |||
$wrapper.append( $( '<div>' ).append( $extra ) ); | |||
} ); | |||
} | |||
var $button = $( '<button>' ).text( 'Create case' ); | |||
var $status = $( '<span>' ).addClass( 'gar-instant-status' ); | |||
$wrapper.append( $button, ' ', $status ); | |||
$container.empty().append( $wrapper ); | |||
function submit() { | |||
$status.text( ' Finding the next case number...' ); | |||
var fields = {}; | |||
for ( var key in extraInputs ) { | |||
fields[ key ] = $.trim( extraInputs[ key ].val() ); | |||
} | |||
mw.loader.using( 'mediawiki.api' ).then( function () { | |||
var api = new mw.Api(); | |||
var noNs = String( person ).replace( /^[^:]+:/, '' ); | |||
api.get( { | |||
action: 'query', | |||
list: 'allpages', | |||
apnamespace: GAR_NS, | |||
apprefix: noNs + '/Case-', | |||
aplimit: 'max', | |||
formatversion: 2, | |||
format: 'json' | |||
} ).then( function ( data ) { | |||
var pages = ( data.query && data.query.allpages ) || []; | |||
var max = 0; | |||
pages.forEach( function ( p ) { | |||
var m = p.title.match( /\/Case-(\d+)$/ ); | |||
if ( m ) { | |||
var num = parseInt( m[ 1 ], 10 ); | |||
if ( num > max ) { | |||
max = num; | |||
} | |||
} | |||
} ); | |||
var caseName = 'Case-' + pad7( max + 1 ); | |||
runInstantCreate( person + '/' + caseName, preloadTitle, fields, $status ); | |||
}, function ( code ) { | |||
$status.text( ' Could not check case numbers (' + code + ').' ); | |||
} ); | |||
} ); | |||
} | |||
$button.on( 'click', submit ); | |||
} | |||
/* ========================================================== | |||
Set the person's photo (shown on the person's own page). | |||
Rewrites {{PersonPhoto|...}} on the current page. | |||
========================================================== */ | |||
function readCurrentPage( api ) { | |||
var pageTitle = mw.config.get( 'wgPageName' ); | |||
return api.get( { | |||
action: 'query', | |||
titles: pageTitle, | |||
prop: 'revisions', | |||
rvprop: 'content', | |||
rvslots: 'main', | |||
formatversion: 2, | |||
format: 'json' | |||
} ).then( function ( data ) { | |||
var pages = ( data.query && data.query.pages ) || []; | |||
var raw = ''; | |||
pages.forEach( function ( p ) { | |||
if ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main ) { | |||
raw = p.revisions[ 0 ].slots.main.content; | |||
} | |||
} ); | |||
return { title: pageTitle, text: raw }; | |||
} ); | } ); | ||
} | } | ||
/* ---------- | function buildSetPhoto( $container ) { | ||
if ( !markDone( $container ) ) { | |||
return; | |||
} | |||
var $input = $( '<input>' ).attr( 'type', 'text' ).attr( 'placeholder', 'Uploaded photo filename (e.g. Stevie.jpg)' ).css( 'width', '100%' ).css( 'margin-bottom', '6px' ); | |||
var $upload = $( '<a>' ).attr( 'href', mw.util.getUrl( 'Special:Upload' ) ).text( 'Upload a photo first' ).css( 'margin-right', '10px' ); | |||
var $button = $( '<button>' ).text( 'Set photo' ); | |||
var $status = $( '<span>' ).addClass( 'gar-instant-status' ); | |||
$container.empty().append( | |||
$( '<div>' ).append( $input ), | |||
$( '<div>' ).append( $upload, $button, ' ', $status ) | |||
); | |||
function submit() { | |||
var fn = sanitizeFilename( $input.val() ); | |||
if ( !fn ) { | |||
mw.notify( 'Please enter a valid image filename.', { type: 'error', tag: 'gar-photo' } ); | |||
return; | |||
} | |||
$status.text( ' Saving photo...' ); | |||
mw.loader.using( 'mediawiki.api' ).then( function () { | |||
var api = new mw.Api(); | |||
readCurrentPage( api ).then( function ( page ) { | |||
if ( !page.text ) { | |||
$status.text( ' Could not read this page.' ); | |||
return; | |||
} | |||
var replaced = page.text.replace( /\{\{PersonPhoto\s*\|[^}]*\}\}/, '{{PersonPhoto|' + escapeWikitext( fn ) + '}}' ); | |||
if ( replaced === page.text ) { | |||
$status.text( ' Could not find the photo slot on this page.' ); | |||
return; | |||
} | |||
api.postWithToken( 'csrf', { | |||
action: 'edit', | |||
title: page.title, | |||
text: replaced, | |||
formatversion: 2, | |||
summary: 'Set photo' | |||
} ).then( function () { | |||
window.location.reload(); | |||
}, function ( code ) { | |||
$status.text( ' Save failed (' + code + ').' ); | |||
} ); | |||
} ); | |||
} ); | |||
} | |||
$button.on( 'click', submit ); | |||
$input.on( 'keydown', function ( e ) { | |||
if ( e.which === 13 ) { | |||
e.preventDefault(); | |||
submit(); | |||
} | |||
} ); | |||
} | |||
/* ========================================================== | |||
Editable "details" text box on the person page. | |||
Rewrites the text between the DESC markers. | |||
========================================================== */ | |||
function buildEditable( $container ) { | |||
if ( !markDone( $container ) ) { | |||
return; | |||
} | |||
var START = '<!--GAR-DESC-START-->'; | |||
var END = '<!--GAR-DESC-END-->'; | |||
var $textarea = $( '<textarea>' ).attr( 'rows', 4 ).css( 'width', '100%' ).css( 'margin-bottom', '6px' ).attr( 'placeholder', 'Add or update details about this person...' ); | |||
var $button = $( '<button>' ).text( 'Save details' ); | |||
var $status = $( '<span>' ).addClass( 'gar-instant-status' ); | |||
$container.empty().append( | |||
$( '<div>' ).append( $textarea ), | |||
$( '<div>' ).append( $button, ' ', $status ) | |||
); | |||
mw.loader.using( 'mediawiki.api' ).then( function () { | |||
var api = new mw.Api(); | |||
readCurrentPage( api ).then( function ( page ) { | |||
var s = page.text.indexOf( START ); | |||
var e = page.text.indexOf( END ); | |||
if ( s > -1 && e > -1 ) { | |||
$textarea.val( page.text.substring( s + START.length, e ) ); | |||
} | |||
} ); | |||
function submit() { | |||
var val = $textarea.val(); | |||
$status.text( ' Saving...' ); | |||
readCurrentPage( api ).then( function ( page ) { | |||
var s = page.text.indexOf( START ); | |||
var e = page.text.indexOf( END ); | |||
if ( s < 0 || e < 0 ) { | |||
$status.text( ' Could not find the details area on this page.' ); | |||
return; | |||
} | |||
var newRaw = page.text.substring( 0, s + START.length ) + escapeWikitext( val ) + page.text.substring( e ); | |||
api.postWithToken( 'csrf', { | |||
action: 'edit', | |||
title: page.title, | |||
text: newRaw, | |||
formatversion: 2, | |||
summary: 'Update details' | |||
} ).then( function () { | |||
window.location.reload(); | |||
}, function ( code ) { | |||
$status.text( ' Save failed (' + code + ').' ); | |||
} ); | |||
} ); | |||
} | |||
$button.on( 'click', submit ); | |||
} ); | |||
} | |||
/* ========================================================== | |||
A-Z letter grid. Batch-checks which letters already exist, | |||
turns those into ordinary links (no "Creating"), and leaves | |||
only genuinely-new letters as clickable "add" links. | |||
========================================================== */ | |||
function createLetter( $el ) { | |||
var title = $el.data( 'title' ); | |||
var preload = $el.data( 'preload' ); | |||
var $status = $( '<span>' ).addClass( 'gar-instant-status' ); | |||
$el.after( $status ); | |||
$el.off( 'click' ); | |||
$el.css( 'cursor', 'default' ); | |||
runInstantCreate( title, preload, {}, $status ); | |||
} | |||
function processLetterGrid( $content ) { | |||
var $letters = $content.find( '.gar-instant-letter' ).filter( function () { | |||
return !$( this ).data( 'garDone' ); | |||
} ); | |||
if ( !$letters.length ) { | |||
return; | |||
} | |||
$letters.each( function () { | |||
$( this ).data( 'garDone', true ); | |||
} ); | |||
var titles = []; | |||
$letters.each( function () { | |||
titles.push( String( $( this ).data( 'title' ) ) ); | |||
} ); | |||
var uniq = titles.filter( function ( t, i ) { | |||
return titles.indexOf( t ) === i; | |||
} ); | |||
mw.loader.using( 'mediawiki.api' ).then( function () { | |||
var api = new mw.Api(); | |||
var existsMap = {}; | |||
var chunks = []; | |||
var i; | |||
for ( i = 0; i < uniq.length; i += 50 ) { | |||
chunks.push( uniq.slice( i, i + 50 ) ); | |||
} | |||
var seq = Promise.resolve(); | |||
chunks.forEach( function ( chunk ) { | |||
seq = seq.then( function () { | |||
return api.get( { | |||
action: 'query', | |||
titles: chunk.join( '|' ), | |||
formatversion: 2, | |||
format: 'json' | |||
} ).then( function ( data ) { | |||
var pages = ( data.query && data.query.pages ) || []; | |||
pages.forEach( function ( p ) { | |||
existsMap[ p.title.replace( /_/g, ' ' ) ] = !p.missing; | |||
} ); | |||
} ); | |||
} ); | |||
} ); | |||
seq.then( function () { | |||
$letters.each( function () { | |||
var $el = $( this ); | |||
var title = String( $el.data( 'title' ) ).replace( /_/g, ' ' ); | |||
if ( existsMap[ title ] ) { | |||
var $link = $( '<a>' ) | |||
.attr( 'href', mw.util.getUrl( $el.data( 'title' ) ) ) | |||
.addClass( 'gar-letter-exists' ) | |||
.text( $el.text() ); | |||
$el.replaceWith( $link ); | |||
} else { | |||
$el.addClass( 'gar-letter-new' ); | |||
$el.on( 'click', function () { | |||
createLetter( $el ); | |||
} ); | |||
} | |||
} ); | |||
} ); | |||
} ); | |||
} | |||
/* ========================================================== | |||
Wire everything up | |||
========================================================== */ | |||
mw.hook( 'wikipage.content' ).add( function ( $content ) { | mw.hook( 'wikipage.content' ).add( function ( $content ) { | ||
$content.find( '.gar-auto-grid' ).each( function () { | $content.find( '.gar-auto-grid' ).each( function () { | ||
| Line 259: | Line 677: | ||
buildForm( $( this ) ); | buildForm( $( this ) ); | ||
} ); | } ); | ||
$content.find( '.gar- | $content.find( '.gar-create-case' ).each( function () { | ||
buildCreateCase( $( this ) ); | |||
} ); | |||
$content.find( '.gar-set-photo' ).each( function () { | |||
buildSetPhoto( $( this ) ); | |||
} ); | |||
$content.find( '.gar-editable' ).each( function () { | |||
buildEditable( $( this ) ); | |||
} ); | } ); | ||
processLetterGrid( $content ); | |||
} ); | } ); | ||
}() ); | }() ); | ||
Revision as of 17:03, 8 July 2026
( function () {
'use strict';
var GAR_NS = 3000; // NS_GAR
/* ==========================================================
Safety helpers
========================================================== */
// Neutralise wiki + HTML characters so a typed value cannot inject
// templates, links, parameters, or parser/HTML tags.
function escapeWikitext( str ) {
return String( str )
.replace( /\{/g, '{' )
.replace( /\}/g, '}' )
.replace( /\[/g, '[' )
.replace( /\]/g, ']' )
.replace( /\|/g, '|' )
.replace( /</g, '<' )
.replace( />/g, '>' );
}
// Escape $ so a value is safe inside a JS String.replace() replacement.
function safeReplacement( str ) {
return str.replace( /\$/g, '$$$$' );
}
// Reject a typed page name that contains title-illegal characters or a
// slash (which would silently create an off-structure subpage). Returns
// a cleaned name, or null if it must be refused.
function sanitizeName( name ) {
var n = String( name ).replace( /\s+/g, ' ' ).trim();
if ( !n ) {
return null;
}
if ( /[#<>\[\]{}|/]/.test( n ) ) {
return null;
}
return n;
}
// Filenames: same idea, but a filename legitimately may contain dots.
function sanitizeFilename( name ) {
var n = String( name ).replace( /\s+/g, ' ' ).trim();
if ( !n ) {
return null;
}
if ( /[#<>\[\]{}|]/.test( n ) ) {
return null;
}
return n;
}
// Strip a leading "Namespace:" from a title, leaving the bare path.
// (Titles come back from the API with spaces, e.g. "GAR:YouTube/A".)
function stripNs( title ) {
var t = String( title );
var idx = t.indexOf( ':' );
return idx > -1 ? t.slice( idx + 1 ) : t;
}
// If the create-prefix ends in a single A-Z letter, return it (else null).
function bucketLetterFromPrefix( prefix ) {
var p = String( prefix ).replace( /\/$/, '' );
var seg = p.split( '/' ).pop();
return /^[A-Za-z]$/.test( seg ) ? seg.toUpperCase() : null;
}
// First letter of a typed name, accents removed, upper-cased.
function firstLetter( name ) {
return name.trim().charAt( 0 ).normalize( 'NFD' ).replace( /[\u0300-\u036f]/g, '' ).toUpperCase();
}
// Zero-pad a number to 7 digits (Case-0000001).
function pad7( n ) {
var s = String( n );
while ( s.length < 7 ) {
s = '0' + s;
}
return s;
}
// Mimic MediaWiki's preload transform on raw template text:
// <onlyinclude> wins, <noinclude> removed, <includeonly> unwrapped.
// Lets preload templates be wrapped in <includeonly> (so the template
// page itself renders empty and joins no category) while the real page
// still receives clean wikitext.
function stripPreload( text ) {
var only = text.match( /<onlyinclude>([\s\S]*?)<\/onlyinclude>/gi );
if ( only ) {
text = only.map( function ( s ) {
return s.replace( /<\/?onlyinclude>/gi, '' );
} ).join( '' );
}
text = text.replace( /<noinclude>[\s\S]*?<\/noinclude>/gi, '' );
text = text.replace( /<\/?includeonly>/gi, '' );
return text.replace( /^\n+/, '' );
}
function markDone( $el ) {
if ( $el.data( 'garDone' ) ) {
return false;
}
$el.data( 'garDone', true );
return true;
}
/* ==========================================================
Live auto-updating grid. Lists real GAR pages by their
subpage structure (not categories), so nothing has to be
"registered" and template pages can never appear as tiles.
Two modes, set on the div:
data-listmode="platforms" -> top-level GAR pages (no "/"),
excluding the system pages.
data-listmode="children" -> the direct sub-pages of
data-parent (its immediate
children only, not grandchildren).
========================================================== */
var GAR_SYSTEM_PAGES = { 'Main': true, 'Offline': true };
function renderAutoGrid( $container ) {
if ( !markDone( $container ) ) {
return;
}
var mode = $container.data( 'listmode' ) || 'children';
$container.text( 'Loading...' );
mw.loader.using( 'mediawiki.api' ).then( function () {
var api = new mw.Api();
if ( mode === 'platforms' ) {
return api.get( {
action: 'query',
list: 'allpages',
apnamespace: GAR_NS,
aplimit: 'max',
formatversion: 2,
format: 'json'
} ).then( function ( data ) {
var pages = ( data.query && data.query.allpages ) || [];
var items = [];
pages.forEach( function ( p ) {
var path = stripNs( p.title );
if ( path.indexOf( '/' ) === -1 && !GAR_SYSTEM_PAGES[ path ] ) {
items.push( { title: p.title, label: path } );
}
} );
return items;
} );
}
// mode === 'children'
var parent = $container.data( 'parent' ) || mw.config.get( 'wgPageName' );
var parentPath = stripNs( String( parent ).replace( /_/g, ' ' ) );
var prefix = parentPath + '/';
return api.get( {
action: 'query',
list: 'allpages',
apnamespace: GAR_NS,
apprefix: prefix,
aplimit: 'max',
formatversion: 2,
format: 'json'
} ).then( function ( data ) {
var pages = ( data.query && data.query.allpages ) || [];
var items = [];
pages.forEach( function ( p ) {
var path = stripNs( p.title );
if ( path.indexOf( prefix ) === 0 ) {
var rest = path.slice( prefix.length );
if ( rest && rest.indexOf( '/' ) === -1 ) {
items.push( { title: p.title, label: rest } );
}
}
} );
return items;
} );
} ).then( function ( items ) {
$container.empty();
if ( !items || items.length === 0 ) {
$container.append( $( '<p>' ).text( 'Nothing has been added here yet.' ) );
return;
}
items.sort( function ( a, b ) {
return a.label.localeCompare( b.label );
} );
items.forEach( function ( it ) {
$container.append(
$( '<a>' ).attr( 'href', mw.util.getUrl( it.title ) ).text( it.label )
);
} );
}, function () {
$container.text( 'Could not load this list right now.' );
} );
}
/* ==========================================================
Instant-create core
========================================================== */
function fallbackToManualEditor( newTitle, preloadTitle, $status, reason ) {
console.error( 'GAR instant-create fell back to manual editor. Reason: ' + reason + '. Title: ' + newTitle );
$status.text( ' Opening the normal editor instead...' );
window.location.href = mw.util.getUrl( newTitle, { action: 'edit', preload: preloadTitle } );
}
function applyExtraFields( rawText, fields ) {
var text = rawText;
if ( fields.image ) {
text = text.replace( /\|image(\s*)=\s*\n/, function ( match, spacing ) {
return '|image' + spacing + '= ' + safeReplacement( escapeWikitext( fields.image ) ) + '\n';
} );
}
if ( fields.evidence ) {
text = text.replace( '<Add Picture Here>', safeReplacement( escapeWikitext( fields.evidence ) ) );
}
if ( fields.description ) {
text = text.replace( '<Write Text Here>', safeReplacement( escapeWikitext( fields.description ) ) );
}
return text;
}
function runInstantCreate( newTitle, preloadTitle, fields, $status ) {
$status.text( ' Creating...' );
mw.loader.using( 'mediawiki.api' ).then( function () {
var api = new mw.Api();
api.get( {
action: 'query',
titles: newTitle,
formatversion: 2,
format: 'json'
} ).then( function ( data ) {
var pages = ( data.query && data.query.pages ) || [];
var exists = false;
pages.forEach( function ( page ) {
if ( !page.missing ) {
exists = true;
}
} );
if ( exists ) {
$status.text( ' Already exists, opening it...' );
window.location.href = mw.util.getUrl( newTitle );
return;
}
api.get( {
action: 'query',
titles: preloadTitle,
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
formatversion: 2,
format: 'json'
} ).then( function ( tplData ) {
var tplPages = ( tplData.query && tplData.query.pages ) || [];
var rawText = '';
tplPages.forEach( function ( page ) {
if ( page.revisions && page.revisions[ 0 ] && page.revisions[ 0 ].slots && page.revisions[ 0 ].slots.main ) {
rawText = page.revisions[ 0 ].slots.main.content;
}
} );
if ( !rawText ) {
fallbackToManualEditor( newTitle, preloadTitle, $status, 'preload template came back empty' );
return;
}
var finalText = applyExtraFields( stripPreload( rawText ), fields );
api.postWithToken( 'csrf', {
action: 'edit',
title: newTitle,
text: finalText,
createonly: true,
formatversion: 2,
summary: 'Auto-created'
} ).then( function ( editResult ) {
if ( editResult && editResult.edit && editResult.edit.result === 'Success' ) {
window.location.href = mw.util.getUrl( newTitle );
} else {
fallbackToManualEditor( newTitle, preloadTitle, $status, 'edit call did not report Success' );
}
}, function ( code ) {
if ( code === 'articleexists' ) {
window.location.href = mw.util.getUrl( newTitle );
return;
}
fallbackToManualEditor( newTitle, preloadTitle, $status, 'edit call failed: ' + code );
} );
}, function ( code ) {
fallbackToManualEditor( newTitle, preloadTitle, $status, 'template fetch failed: ' + code );
} );
}, function ( code ) {
fallbackToManualEditor( newTitle, preloadTitle, $status, 'existence check failed: ' + code );
} );
} );
}
/* ==========================================================
Typed-name forms (platforms, countries, cities, people)
========================================================== */
function buildForm( $container ) {
if ( !markDone( $container ) ) {
return;
}
var prefix = $container.data( 'prefix' );
var preloadTitle = $container.data( 'preload' );
var placeholder = $container.data( 'placeholder' ) || 'Enter a name';
var buttonLabel = $container.data( 'buttonlabel' ) || 'Add';
var extraFieldsRaw = $container.data( 'extrafields' ) || '';
var $input = $( '<input>' ).attr( 'type', 'text' ).attr( 'placeholder', placeholder ).css( 'width', '100%' ).css( 'margin-bottom', '6px' );
var $wrapper = $( '<div>' ).append( $input );
var extraInputs = {};
var plainExtraInputs = [];
if ( extraFieldsRaw ) {
extraFieldsRaw.split( ';' ).forEach( function ( pair ) {
var parts = pair.split( ':' );
var key = $.trim( parts[ 0 ] );
var label = $.trim( parts[ 1 ] || key );
var $extra;
if ( key === 'description' ) {
$extra = $( '<textarea>' ).attr( 'placeholder', label ).attr( 'rows', 3 ).css( 'width', '100%' ).css( 'margin-bottom', '6px' );
} else {
$extra = $( '<input>' ).attr( 'type', 'text' ).attr( 'placeholder', label ).css( 'width', '100%' ).css( 'margin-bottom', '6px' );
plainExtraInputs.push( $extra );
}
extraInputs[ key ] = $extra;
$wrapper.append( $( '<div>' ).append( $extra ) );
} );
}
var $button = $( '<button>' ).text( buttonLabel );
var $status = $( '<span>' ).addClass( 'gar-instant-status' );
$wrapper.append( $button, ' ', $status );
$container.empty().append( $wrapper );
var requiredLetter = bucketLetterFromPrefix( prefix );
function submit() {
var name = sanitizeName( $input.val() );
if ( !name ) {
mw.notify( 'Please use a plain name without the characters / # < > [ ] { } |', { type: 'error', tag: 'gar-name' } );
return;
}
if ( requiredLetter && firstLetter( name ) !== requiredLetter ) {
$status.text( '' );
mw.notify(
'"' + name + '" does not start with the letter "' + requiredLetter +
'". Pick the correct letter, or fix the spelling.',
{ type: 'error', tag: 'gar-letter' }
);
return;
}
var fields = {};
for ( var key in extraInputs ) {
fields[ key ] = $.trim( extraInputs[ key ].val() );
}
runInstantCreate( prefix + name, preloadTitle, fields, $status );
}
$button.on( 'click', submit );
$input.on( 'keydown', function ( e ) {
if ( e.which === 13 ) {
e.preventDefault();
submit();
}
} );
plainExtraInputs.forEach( function ( $extra ) {
$extra.on( 'keydown', function ( e ) {
if ( e.which === 13 ) {
e.preventDefault();
submit();
}
} );
} );
}
/* ==========================================================
"Create case" button: auto-numbers Case-0000001, 0000002 ...
========================================================== */
function buildCreateCase( $container ) {
if ( !markDone( $container ) ) {
return;
}
var person = $container.data( 'person' ); // e.g. GAR:Aaa/A/Australia/A/Adelaide/Stevie
var preloadTitle = $container.data( 'preload' ); // Template:Preload/Case
var extraFieldsRaw = $container.data( 'extrafields' ) || '';
var $wrapper = $( '<div>' );
var extraInputs = {};
if ( extraFieldsRaw ) {
extraFieldsRaw.split( ';' ).forEach( function ( pair ) {
var parts = pair.split( ':' );
var key = $.trim( parts[ 0 ] );
var label = $.trim( parts[ 1 ] || key );
var $extra;
if ( key === 'description' ) {
$extra = $( '<textarea>' ).attr( 'placeholder', label ).attr( 'rows', 3 ).css( 'width', '100%' ).css( 'margin-bottom', '6px' );
} else {
$extra = $( '<input>' ).attr( 'type', 'text' ).attr( 'placeholder', label ).css( 'width', '100%' ).css( 'margin-bottom', '6px' );
}
extraInputs[ key ] = $extra;
$wrapper.append( $( '<div>' ).append( $extra ) );
} );
}
var $button = $( '<button>' ).text( 'Create case' );
var $status = $( '<span>' ).addClass( 'gar-instant-status' );
$wrapper.append( $button, ' ', $status );
$container.empty().append( $wrapper );
function submit() {
$status.text( ' Finding the next case number...' );
var fields = {};
for ( var key in extraInputs ) {
fields[ key ] = $.trim( extraInputs[ key ].val() );
}
mw.loader.using( 'mediawiki.api' ).then( function () {
var api = new mw.Api();
var noNs = String( person ).replace( /^[^:]+:/, '' );
api.get( {
action: 'query',
list: 'allpages',
apnamespace: GAR_NS,
apprefix: noNs + '/Case-',
aplimit: 'max',
formatversion: 2,
format: 'json'
} ).then( function ( data ) {
var pages = ( data.query && data.query.allpages ) || [];
var max = 0;
pages.forEach( function ( p ) {
var m = p.title.match( /\/Case-(\d+)$/ );
if ( m ) {
var num = parseInt( m[ 1 ], 10 );
if ( num > max ) {
max = num;
}
}
} );
var caseName = 'Case-' + pad7( max + 1 );
runInstantCreate( person + '/' + caseName, preloadTitle, fields, $status );
}, function ( code ) {
$status.text( ' Could not check case numbers (' + code + ').' );
} );
} );
}
$button.on( 'click', submit );
}
/* ==========================================================
Set the person's photo (shown on the person's own page).
Rewrites {{PersonPhoto|...}} on the current page.
========================================================== */
function readCurrentPage( api ) {
var pageTitle = mw.config.get( 'wgPageName' );
return api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
formatversion: 2,
format: 'json'
} ).then( function ( data ) {
var pages = ( data.query && data.query.pages ) || [];
var raw = '';
pages.forEach( function ( p ) {
if ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main ) {
raw = p.revisions[ 0 ].slots.main.content;
}
} );
return { title: pageTitle, text: raw };
} );
}
function buildSetPhoto( $container ) {
if ( !markDone( $container ) ) {
return;
}
var $input = $( '<input>' ).attr( 'type', 'text' ).attr( 'placeholder', 'Uploaded photo filename (e.g. Stevie.jpg)' ).css( 'width', '100%' ).css( 'margin-bottom', '6px' );
var $upload = $( '<a>' ).attr( 'href', mw.util.getUrl( 'Special:Upload' ) ).text( 'Upload a photo first' ).css( 'margin-right', '10px' );
var $button = $( '<button>' ).text( 'Set photo' );
var $status = $( '<span>' ).addClass( 'gar-instant-status' );
$container.empty().append(
$( '<div>' ).append( $input ),
$( '<div>' ).append( $upload, $button, ' ', $status )
);
function submit() {
var fn = sanitizeFilename( $input.val() );
if ( !fn ) {
mw.notify( 'Please enter a valid image filename.', { type: 'error', tag: 'gar-photo' } );
return;
}
$status.text( ' Saving photo...' );
mw.loader.using( 'mediawiki.api' ).then( function () {
var api = new mw.Api();
readCurrentPage( api ).then( function ( page ) {
if ( !page.text ) {
$status.text( ' Could not read this page.' );
return;
}
var replaced = page.text.replace( /\{\{PersonPhoto\s*\|[^}]*\}\}/, '{{PersonPhoto|' + escapeWikitext( fn ) + '}}' );
if ( replaced === page.text ) {
$status.text( ' Could not find the photo slot on this page.' );
return;
}
api.postWithToken( 'csrf', {
action: 'edit',
title: page.title,
text: replaced,
formatversion: 2,
summary: 'Set photo'
} ).then( function () {
window.location.reload();
}, function ( code ) {
$status.text( ' Save failed (' + code + ').' );
} );
} );
} );
}
$button.on( 'click', submit );
$input.on( 'keydown', function ( e ) {
if ( e.which === 13 ) {
e.preventDefault();
submit();
}
} );
}
/* ==========================================================
Editable "details" text box on the person page.
Rewrites the text between the DESC markers.
========================================================== */
function buildEditable( $container ) {
if ( !markDone( $container ) ) {
return;
}
var START = '<!--GAR-DESC-START-->';
var END = '<!--GAR-DESC-END-->';
var $textarea = $( '<textarea>' ).attr( 'rows', 4 ).css( 'width', '100%' ).css( 'margin-bottom', '6px' ).attr( 'placeholder', 'Add or update details about this person...' );
var $button = $( '<button>' ).text( 'Save details' );
var $status = $( '<span>' ).addClass( 'gar-instant-status' );
$container.empty().append(
$( '<div>' ).append( $textarea ),
$( '<div>' ).append( $button, ' ', $status )
);
mw.loader.using( 'mediawiki.api' ).then( function () {
var api = new mw.Api();
readCurrentPage( api ).then( function ( page ) {
var s = page.text.indexOf( START );
var e = page.text.indexOf( END );
if ( s > -1 && e > -1 ) {
$textarea.val( page.text.substring( s + START.length, e ) );
}
} );
function submit() {
var val = $textarea.val();
$status.text( ' Saving...' );
readCurrentPage( api ).then( function ( page ) {
var s = page.text.indexOf( START );
var e = page.text.indexOf( END );
if ( s < 0 || e < 0 ) {
$status.text( ' Could not find the details area on this page.' );
return;
}
var newRaw = page.text.substring( 0, s + START.length ) + escapeWikitext( val ) + page.text.substring( e );
api.postWithToken( 'csrf', {
action: 'edit',
title: page.title,
text: newRaw,
formatversion: 2,
summary: 'Update details'
} ).then( function () {
window.location.reload();
}, function ( code ) {
$status.text( ' Save failed (' + code + ').' );
} );
} );
}
$button.on( 'click', submit );
} );
}
/* ==========================================================
A-Z letter grid. Batch-checks which letters already exist,
turns those into ordinary links (no "Creating"), and leaves
only genuinely-new letters as clickable "add" links.
========================================================== */
function createLetter( $el ) {
var title = $el.data( 'title' );
var preload = $el.data( 'preload' );
var $status = $( '<span>' ).addClass( 'gar-instant-status' );
$el.after( $status );
$el.off( 'click' );
$el.css( 'cursor', 'default' );
runInstantCreate( title, preload, {}, $status );
}
function processLetterGrid( $content ) {
var $letters = $content.find( '.gar-instant-letter' ).filter( function () {
return !$( this ).data( 'garDone' );
} );
if ( !$letters.length ) {
return;
}
$letters.each( function () {
$( this ).data( 'garDone', true );
} );
var titles = [];
$letters.each( function () {
titles.push( String( $( this ).data( 'title' ) ) );
} );
var uniq = titles.filter( function ( t, i ) {
return titles.indexOf( t ) === i;
} );
mw.loader.using( 'mediawiki.api' ).then( function () {
var api = new mw.Api();
var existsMap = {};
var chunks = [];
var i;
for ( i = 0; i < uniq.length; i += 50 ) {
chunks.push( uniq.slice( i, i + 50 ) );
}
var seq = Promise.resolve();
chunks.forEach( function ( chunk ) {
seq = seq.then( function () {
return api.get( {
action: 'query',
titles: chunk.join( '|' ),
formatversion: 2,
format: 'json'
} ).then( function ( data ) {
var pages = ( data.query && data.query.pages ) || [];
pages.forEach( function ( p ) {
existsMap[ p.title.replace( /_/g, ' ' ) ] = !p.missing;
} );
} );
} );
} );
seq.then( function () {
$letters.each( function () {
var $el = $( this );
var title = String( $el.data( 'title' ) ).replace( /_/g, ' ' );
if ( existsMap[ title ] ) {
var $link = $( '<a>' )
.attr( 'href', mw.util.getUrl( $el.data( 'title' ) ) )
.addClass( 'gar-letter-exists' )
.text( $el.text() );
$el.replaceWith( $link );
} else {
$el.addClass( 'gar-letter-new' );
$el.on( 'click', function () {
createLetter( $el );
} );
}
} );
} );
} );
}
/* ==========================================================
Wire everything up
========================================================== */
mw.hook( 'wikipage.content' ).add( function ( $content ) {
$content.find( '.gar-auto-grid' ).each( function () {
renderAutoGrid( $( this ) );
} );
$content.find( '.gar-instant-create' ).each( function () {
buildForm( $( this ) );
} );
$content.find( '.gar-create-case' ).each( function () {
buildCreateCase( $( this ) );
} );
$content.find( '.gar-set-photo' ).each( function () {
buildSetPhoto( $( this ) );
} );
$content.find( '.gar-editable' ).each( function () {
buildEditable( $( this ) );
} );
processLetterGrid( $content );
} );
}() );