User:Wilfredor/commons-nominator.js
Appearance
Note: After saving, you have to bypass your browser's cache to see the changes. Internet Explorer: press Ctrl-F5, Mozilla: hold down Shift while clicking Reload (or press Ctrl-Shift-R), Opera/Konqueror: press F5, Safari: hold down Shift + Alt while clicking Reload, Chrome: hold down Shift while clicking Reload.
| This user script seems to have a documentation page at User:Wilfredor/commons-nominator. |
// [[Category:User scripts by Wilfredor|commons-nominator]] <nowiki>
/*
* Commons Nominator, Wilfredor
*
* Adds "Nominate forβ¦" links on Commons File: pages for the three
* image-quality processes, and files the nomination with ONE edit per
* explicit click (strictly human-paced, nothing is written without a click):
*
* β’ π Featured Picture (FPC), creates the nomination subpage and
* transcludes it at the top of the candidate list. Enforces the
* 2-active-nomination cap (rule 11). [extracted from fpc-archiver.js]
* β’ π
Quality Image (QIC), adds one gallery line under today's UTC
* date heading on the QIC candidate list. Enforces the 5-per-day cap.
* β’ β Valued Image (VIC), creates the nomination subpage and appends
* the file to the \u007b{VICs}} list. No daily/open cap (per-scope uniqueness
* is a reviewer judgement, surfaced as a reminder).
*
* The FP nomination flow (button + dialog + gallery picker + submit) was moved
* out of fpc-archiver.js verbatim; the archiver is now the closer + EXIF panel
* only. The deep image-quality engine (camera DB, diffraction, sharpness) is
* NOT duplicated here, the dialog shows lightweight, objective hard-rule
* checks only.
*
* Install: add to Special:MyPage/common.js
* mw.loader.load('/w/index.php?title=User:Wilfredor/commons-nominator.js&action=raw&ctype=text/javascript');
*/
( function () {
'use strict';
var ns = mw.config.get( 'wgNamespaceNumber' );
var page = mw.config.get( 'wgPageName' ) || '';
var isFilePage = ns === 6 && /^File:/.test( page );
// FPC nomination-subpage editor, the rule-11 banner guard (moved from
// fpc-archiver.js). Fires when the user manually opens the editor for a
// brand-new FPC nomination subpage (File:/Set/removal variants).
var isFpcEditPage = /^Commons:Featured[ _]picture[ _]candidates\/(File:|Set\/|removal\/)/.test( page );
var isFpcNomPage = /^Commons:Featured[ _]picture[ _]candidates\/File:/.test( page );
var isFpcPage = /^Commons:Featured[ _]picture[ _]candidates(?:\/|$)/.test( page );
var isCategoryPage = ns === 14;
if ( !isFilePage && !isFpcPage && !isCategoryPage ) return;
// Canonical page titles used across the three processes.
var QIC_LIST = 'Commons:Quality images candidates/candidate list';
var VIC_LIST = 'Commons:Valued image candidates/candidate list';
// QIC: "No more than five images per day can be added by a single nominator."
var QIC_DAILY_CAP = 5;
// COM:IG, the two megapixel floor for bitmapped images.
var QI_MIN_PIXELS = 2000000;
// How long the prefilled QIC caption may be. See the note where it is used.
var QIC_DESC_MAX = 120;
var VIC_SUBPAGE_PREFIX = 'Commons:Valued image candidates/';
var MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December' ];
// Shared cache for the FP gallery icon map (also the catalog of every known
// leaf gallery name). Populated lazily on the first FP-nominate dialog open.
var fpgIconMapPromise = null;
mw.loader.using( [ 'mediawiki.api', 'mediawiki.notification', 'mediawiki.util', 'mediawiki.ForeignApi' ] ).done( function () {
injectStyles();
var api = new mw.Api();
if ( isFilePage ) {
setupNominateButtons( api );
} else if ( isFpcPage && mw.config.get( 'wgAction' ) === 'view' ) {
setupFpcRenameButtons( api );
} else if ( isFpcEditPage ) {
checkActiveNomLimitOnEdit( api );
} else if ( isCategoryPage ) {
setupCategoryActions( api );
}
} );
// Dispatcher: insert all three nominate links on a File: page. Each link
// runs its own precheck + dialog; they share helpers but never auto-fire.
function setupNominateButtons( api ) {
setupNominateButton( api ); // π FP (full dialog: gallery + samples)
setupQicNominateLink( api ); // π
QIC (β€5/day guard)
setupVicNominateLink( api ); // β VIC (subpage + \u007b{VICs}} append)
setupPotdLink( api ); // π POTD (FP never POTD β next free date)
setupWikidataImageLink( api );// πΌ set this FP as its depicted item's Wikidata image (P18)
setupSuggestArticlesLink( api ); // π find en.wp articles this FP could illustrate
}
function normalizeFileTitle( value ) {
value = String( value || '' ).replace( /_/g, ' ' ).trim();
value = value.replace( /^\s*(?:File|Image)\s*:\s*/i, '' ).replace( /\s+/g, ' ' );
return value ? 'File:' + value : '';
}
function titleCore( title ) {
return String( title || '' ).replace( /^(?:File|Image):/i, '' );
}
function mwTitlePattern( title ) {
return String( title || '' ).replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ).replace( /[ _]+/g, '[ _]+' );
}
function replaceFpcFileRefs( wt, oldFile, newFile ) {
var oldCore = titleCore( oldFile );
var newCore = titleCore( newFile );
var linkRe = new RegExp( '(\\[\\[\\s*:?\\s*)(File|Image)(\\s*:\\s*)' +
mwTitlePattern( oldCore ) + '(?=([\\]|#]))', 'gi' );
wt = wt.replace( linkRe, function ( m, open, nsText, colon ) {
return open + 'File' + colon + newCore;
} );
[
'File:' + oldCore,
'Image:' + oldCore,
'File:' + oldCore.replace( / /g, '_' ),
'Image:' + oldCore.replace( / /g, '_' )
].sort( function ( a, b ) { return b.length - a.length; } ).forEach( function ( old ) {
wt = wt.split( old ).join( 'File:' + newCore );
} );
wt = wt.split( oldCore ).join( newCore );
wt = wt.split( oldCore.replace( / /g, '_' ) ).join( newCore.replace( / /g, '_' ) );
return wt;
}
function replaceFpcNomPageRefs( wt, oldNom, newNom ) {
return wt.replace( new RegExp( mwTitlePattern( oldNom ), 'gi' ), newNom );
}
function currentFpcNomParts() {
var current = ( mw.config.get( 'wgPageName' ) || '' ).replace( /_/g, ' ' );
var m = current.match( /^Commons:Featured picture candidates\/(File:.+?)(\/\d+)?$/ );
if ( !m ) return null;
return {
oldNom: current,
oldFile: normalizeFileTitle( m[ 1 ] ),
suffix: m[ 2 ] || ''
};
}
function fpcNomPartsFromTitle( nomPage ) {
nomPage = String( nomPage || '' ).replace( /_/g, ' ' );
var m = nomPage.match( /^Commons:Featured picture candidates\/(File:.+?)(\/\d+)?$/ );
if ( !m ) return null;
return {
oldNom: nomPage,
oldFile: normalizeFileTitle( m[ 1 ] ),
suffix: m[ 2 ] || ''
};
}
function headingNomPageForRename( head ) {
var editLink = head && head.querySelector && head.querySelector( '.mw-editsection a[href*="title="]' );
if ( editLink ) {
try {
var url = new URL( editLink.href, location.href );
var title = ( url.searchParams.get( 'title' ) || '' ).replace( /_/g, ' ' );
if ( /^Commons:Featured picture candidates\/File:/.test( title ) ) return title;
} catch ( e ) {}
}
var fileLink = head && head.querySelector && head.querySelector( 'a[href*="/wiki/File:"]' );
if ( fileLink ) {
var raw = fileLink.getAttribute( 'href' ) || '';
try { raw = decodeURIComponent( raw.replace( /^\/wiki\//, '' ) ).replace( /_/g, ' ' ); } catch ( e2 ) {}
if ( /^File:/.test( raw ) ) return 'Commons:Featured picture candidates/' + raw;
}
return null;
}
function moveWikiPage( oldTitle, newTitle, reason, api ) {
return apiWriteWithMaxlagRetry( function () {
return api.postWithToken( 'csrf', {
action: 'move',
from: oldTitle,
to: newTitle,
reason: reason,
movetalk: 1,
maxlag: 5,
assert: 'user',
formatversion: 2
} );
} );
}
function editWikiPage( title, newText, summary, api ) {
return editWikiPageTransform( title, function () { return newText; }, summary, api );
}
function editWikiPageTransform( title, transform, summary, api ) {
return fetchPage( title, api ).then( function ( pageData ) {
if ( pageData.missing ) throw new Error( 'Missing page: ' + title );
var newText = transform( pageData.wt );
if ( pageData.wt === newText ) return { skipped: true };
return apiWriteWithMaxlagRetry( function () {
return api.postWithToken( 'csrf', {
action: 'edit',
title: title,
text: newText,
summary: summary,
baserevid: pageData.baserevid,
basetimestamp: pageData.basetimestamp,
maxlag: 5,
nocreate: 1,
assert: 'user',
formatversion: 2
} );
}, title );
} );
}
function apiWriteWithMaxlagRetry( makeRequest, label ) {
var waited = 0;
function run() {
return Promise.resolve( makeRequest() ).catch( function ( err ) {
var code = err && ( err.code || err.error && err.error.code ) || err;
var lag = parseFloat( err && ( err.lag || err.error && err.error.lag ) || 0 );
if ( code !== 'maxlag' ) throw err;
if ( waited >= 300 ) throw err;
var wait = Math.max( 5, Math.min( lag || 5, 60 ) );
waited += wait;
try { console.warn( '[commons-nominator] maxlag while writing ' + ( label || 'page' ) + '; retrying in ' + wait + 's' ); } catch ( e ) {}
return new Promise( function ( resolve ) {
setTimeout( resolve, wait * 1000 );
} ).then( run );
} );
}
return run();
}
function addFpcRenameButton( heading, parts, api ) {
if ( !heading || !parts || heading.querySelector( '.cn-fpc-rename-btn' ) ) return;
var btn = document.createElement( 'button' );
btn.className = 'cn-fpc-rename-btn';
btn.type = 'button';
btn.textContent = 'Rename';
btn.title = 'Rename the file and keep this FPC nomination in sync';
btn.addEventListener( 'click', function () {
showFpcRenameDialog( parts, api );
} );
heading.appendChild( btn );
}
function setupFpcRenameButtons( api ) {
var currentParts = currentFpcNomParts();
if ( currentParts ) addFpcRenameButton( document.getElementById( 'firstHeading' ), currentParts, api );
document.querySelectorAll( '.mw-heading' ).forEach( function ( head ) {
var nomPage = headingNomPageForRename( head );
addFpcRenameButton( head, fpcNomPartsFromTitle( nomPage ), api );
} );
}
function showFpcRenameDialog( parts, api ) {
var modal = buildModal( 'Rename FPC file' );
modal.submitBtn.textContent = 'Rename';
var oldCore = titleCore( parts.oldFile );
modal.body.innerHTML =
'<section class="cn-rename-form">' +
' <label>Current file<input class="cn-rename-old" type="text" readonly></label>' +
' <label>New file name<input class="cn-rename-new" type="text" autocomplete="off"></label>' +
' <label>Reason<input class="cn-rename-reason" type="text" autocomplete="off"></label>' +
' <div class="cn-rename-plan"></div>' +
'</section>';
var oldInput = modal.body.querySelector( '.cn-rename-old' );
var newInput = modal.body.querySelector( '.cn-rename-new' );
var reasonInput = modal.body.querySelector( '.cn-rename-reason' );
var planEl = modal.body.querySelector( '.cn-rename-plan' );
oldInput.value = parts.oldFile;
newInput.value = oldCore;
reasonInput.value = 'more descriptive file name';
newInput.select();
function renderPlan() {
var newFile = normalizeFileTitle( newInput.value );
var valid = newFile && newFile !== parts.oldFile;
modal.submitBtn.disabled = !valid;
if ( !valid ) {
planEl.textContent = 'Enter a different file name.';
return;
}
var newNom = 'Commons:Featured picture candidates/' + newFile + parts.suffix;
planEl.innerHTML =
'<b>Will update:</b>' +
'<ul>' +
'<li>' + escapeHtml( parts.oldFile ) + ' -> ' + escapeHtml( newFile ) + '</li>' +
'<li>' + escapeHtml( parts.oldNom ) + ' -> ' + escapeHtml( newNom ) + '</li>' +
'<li>Nomination body and FPC candidate list transclusion</li>' +
'</ul>';
}
newInput.addEventListener( 'input', renderPlan );
renderPlan();
modal.submitBtn.addEventListener( 'click', function () {
var newFile = normalizeFileTitle( newInput.value );
var newNom = 'Commons:Featured picture candidates/' + newFile + parts.suffix;
var reason = reasonInput.value.trim() || 'more descriptive file name';
var moveSummary = reason + '; keep FPC nomination in sync';
var editSummary = 'Update FPC nomination after file rename: [[' + parts.oldFile + ']] -> [[' + newFile + ']]';
modal.submitBtn.disabled = true;
modal.cancelBtn.disabled = true;
modal.status.textContent = 'Reading pages...';
Promise.all( [
fetchPage( parts.oldNom, api ),
fetchPage( 'Commons:Featured picture candidates/candidate list', api ),
fetchPage( newFile, api ),
fetchPage( newNom, api )
] ).then( function ( reads ) {
if ( reads[ 0 ].missing ) throw new Error( 'The nomination page is missing.' );
if ( reads[ 1 ].missing ) throw new Error( 'The FPC candidate list is missing.' );
if ( !reads[ 2 ].missing ) throw new Error( 'The target file already exists: ' + newFile );
if ( !reads[ 3 ].missing ) throw new Error( 'The target nomination page already exists: ' + newNom );
modal.status.textContent = 'Moving file...';
return moveWikiPage( parts.oldFile, newFile, moveSummary, api ).then( function () {
modal.status.textContent = 'Moving nomination...';
return moveWikiPage( parts.oldNom, newNom, moveSummary, api );
} ).then( function () {
modal.status.textContent = 'Updating nomination body...';
return editWikiPageTransform( newNom, function ( wt ) {
return replaceFpcFileRefs( wt, parts.oldFile, newFile );
}, editSummary, api );
} ).then( function () {
modal.status.textContent = 'Updating candidate list...';
return editWikiPageTransform( 'Commons:Featured picture candidates/candidate list', function ( wt ) {
return replaceFpcNomPageRefs( wt, parts.oldNom, newNom );
}, editSummary, api );
} ).then( function () {
modal.status.textContent = 'Done. Opening renamed nomination...';
setTimeout( function () {
window.location.href = '/wiki/' + encodeURIComponent( newNom.replace( / /g, '_' ) );
}, 500 );
} );
} ).catch( function ( err ) {
modal.submitBtn.disabled = false;
modal.cancelBtn.disabled = false;
modal.status.innerHTML = '<span style="color:#b91c1c">' + escapeHtml( err && err.message || err ) + '</span>';
} );
} );
}
// File page: for a FP not yet featured on en.wp, offer article discovery.
function setupSuggestArticlesLink( api ) {
var pageName = mw.config.get( 'wgPageName' ) || '';
if ( !/^File:/.test( pageName ) ) return;
var link = addFileActionLink( 'pt-enfp-suggest', 'π Find en.wp articles',
'List English Wikipedia articles where this featured picture could be placed' );
if ( !link ) return;
var li = link.closest( 'li' ) || link;
li.style.display = 'none'; // only a FP that is not already an en.wp FP qualifies
Promise.all( [ checkFileAssessments( pageName, api ), fetchPage( pageName, api ) ] ).then( function ( r ) {
var a = r[ 0 ], wt = ( r[ 1 ] && r[ 1 ].wt ) || '';
if ( a.isFP && !isAlreadyEnFp( wt ) ) li.style.display = '';
} ).catch( function () {} );
link.addEventListener( 'click', function ( e ) { e.preventDefault(); showSuggestArticlesDialog( pageName, api ); } );
}
// βββ Shared helpers (copied verbatim from fpc-archiver.js) βββββββββββββββ
function escapeAttr( s ) {
return String( s || '' ).replace( /"/g, '"' ).replace( /</g, '<' ).replace( />/g, '>' );
}
function escapeHtml( s ) {
return String( s || '' ).replace( /&/g, '&' ).replace( /</g, '<' ).replace( />/g, '>' )
.replace( /"/g, '"' ).replace( /'/g, ''' );
}
function canonUser( s ) {
s = ( s || '' ).replace( /_/g, ' ' ).trim();
if ( !s ) return '';
return s.charAt( 0 ).toUpperCase() + s.slice( 1 );
}
function detectUploader( wt ) {
// The nominator is the user whose signature is closest to (immediately
// before) the FIRST UTC timestamp in the wikitext, that signature is
// the one the nominator added when creating the nomination.
// Common patterns:
// *\u007b{Info}} β¦ Created by [[User:Creator]] β nominated by --[[User:Nominator]] (talk) 12:00, 1 Jan 2026 (UTC)
// *\u007b{Info}} β¦ My photo. --[[User:Nominator]] 12:00, 1 Jan 2026 (UTC)
var tsRe = /\d{1,2}:\d{2},\s+\d{1,2}\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4}\s*\(UTC\)/;
var tsMatch = wt.match( tsRe );
if ( !tsMatch ) {
// Fallback to first User: anywhere.
var fallback = wt.match( /\[\[User:([^\]|]+)/ );
return fallback ? fallback[ 1 ].trim() : '';
}
var tsPos = tsMatch.index;
var preStart = Math.max( 0, tsPos - 300 );
var pre = wt.slice( preStart, tsPos );
var lastUser = null;
var re = /\[\[\s*:?\s*(?:User(?:[ _]talk)?|Special[ _]?:?[ _]?Contributions)\s*[:/]\s*([^\]|]+?)\s*(?:\|[^\]]*)?\]\]/gi;
var m;
while ( ( m = re.exec( pre ) ) !== null ) {
var target = m[ 1 ].trim();
var pgm = target.match( /^\{\{\s*(?:PAGENAME|ucfirst|lc|uc)\s*:\s*([^}]+?)\s*\}\}$/i );
if ( pgm ) target = pgm[ 1 ].trim();
target = target.replace( /[#/].*$/, '' ).trim();
if ( target ) lastUser = canonUser( target );
}
return lastUser || '';
}
var FP_GALLERY_RULES = [
// ββ Animalia (most specific subcategories first) ββββββββββββββββ
{ re: /\b(birds?|aves|owls?|raptors?|eagles?|hawks?|parrots?|songbirds?|hummingbirds?|finches?|waterfowl|seabirds?)\b/i, gallery: 'Animals/Birds' },
{ re: /\b(mammals?|cetaceans?|whales?|dolphins?|seals?|primates?|cats?|dogs?|wolves|bears?|rodents?|bats?|deer|elephants?|horses?)\b/i, gallery: 'Animals/Mammals' },
{ re: /\b(reptiles?|snakes?|lizards?|turtles?|tortoises?|crocodiles?|alligators?)\b/i, gallery: 'Animals/Reptiles' },
{ re: /\b(amphibians?|frogs?|toads?|salamanders?|newts?)\b/i, gallery: 'Animals/Amphibians' },
{ re: /\b(fish(?:es)?|sharks?|rays|seahorses?|eels?|cichlids?)\b/i, gallery: 'Animals/Fish' },
{ re: /\b(insects?|butterflies|moths?|beetles?|bees?|wasps?|ants?|dragonflies|spiders?|arachnids?|crustaceans?|crabs?|lobsters?|shrimps?)\b/i, gallery: 'Animals/Arthropods' },
{ re: /\b(molluscs?|mollusks?|cephalopods?|octopu(?:s|ses)|squids?|cuttlefish|snails?|nudibranchs?|jellyfish|cnidarians?|corals?|sea anemones?)\b/i, gallery: 'Animals' },
{ re: /\b(animals?|fauna|wildlife|zoo|aquarium)\b/i, gallery: 'Animals' },
// ββ Plants / Mycology βββββββββββββββββββββββββββββββββββββββββββ
{ re: /\b(fungi|mushrooms?|lichens?|slime molds?)\b/i, gallery: 'Fungi' },
{ re: /\b(plants?|flowers?|trees?|shrubs?|herbs?|grasses|ferns?|mosses?|botanic|botanical|orchids?|cacti|cactus|rosaceae|asteraceae|orchidaceae|fabaceae|family\s*:?\s*[A-Z][a-z]+aceae)\b/i, gallery: 'Plants' },
// ββ Places: split by sub-type, then country fall-back βββββββββββ
{ re: /\b(aerial photographs?|aerial views?|aerial photography|from (?:above|the air)|drone (?:photos?|shots?|views?))\b/i, gallery: 'Places/Satellite images' },
{ re: /\b(astronomy|astrophotography|nebulae|galaxies|star fields?|milky way|aurorae?|aurora borealis|aurora australis|moon|lunar|solar eclipses?|planets?)\b/i, gallery: 'Astronomy' },
{ re: /\b(churches?|cathedrals?|basilicas?|mosques?|synagogues?|temples?|monasteries|abbeys|chapels?)\b/i, gallery: 'Places/Architecture/Religious buildings' },
{ re: /\b(castles?|chateaux?|fortresses?|palaces?|fortifications?)\b/i, gallery: 'Places/Architecture/Castles and fortifications' },
{ re: /\b(bridges?|viaducts?|aqueducts?)\b/i, gallery: 'Places/Architecture/Bridges' },
{ re: /\b(skylines?|cityscapes?|urban (?:landscapes?|scenes?))\b/i, gallery: 'Places/Architecture/Cityscapes' },
{ re: /\b(interiors?|inside (?:of |the )?(?:church|cathedral|building|hall|museum)|ceiling|nave|halls?|libraries?)\b/i, gallery: 'Places/Interiors' },
{ re: /\b(buildings?|architecture|towers?|skyscrapers?|stadiums?|villas?|houses?|monuments?|memorials?)\b/i, gallery: 'Places/Architecture/Exteriors' },
{ re: /\b(mountains?|peaks?|valleys?|canyons?|cliffs?|gorges?|fjords?|glaciers?|volcanoes?|caves?|deserts?|dunes?|geological|geology)\b/i, gallery: 'Places/Natural' },
{ re: /\b(forests?|jungles?|woodlands?|meadows?|prairies?|wetlands?|swamps?|tundras?|rainforests?)\b/i, gallery: 'Places/Natural' },
{ re: /\b(beaches?|coast(?:s|al|line)?|seas?|oceans?|lakes?|rivers?|waterfalls?|estuaries|islands?|atolls?)\b/i, gallery: 'Places/Natural' },
{ re: /\b(parks?|gardens?|national parks?|protected areas?|landscape photography|landscapes?|scenery)\b/i, gallery: 'Places/Natural' },
// ββ People / Activities βββββββββββββββββββββββββββββββββββββββββ
{ re: /\b(portraits?|headshots?|self[- ]?portraits?|selfies?)\b/i, gallery: 'People/Portrait' },
{ re: /\b(sports?|athletes?|football|soccer|basketball|tennis|cycling|skiing|surfing|swimming|olympic|olympics?|marathon|races?)\b/i, gallery: 'Sports' },
{ re: /\b(dancers?|dancing|ballet|theatre|theater|opera|performers?|performance|musicians?|orchestras?|concerts?|festivals?|carnivals?|parades?)\b/i, gallery: 'People/Work' },
{ re: /\b(weddings?|ceremonies|religious ceremonies|funerals?|rites?|protests?|demonstrations?|crowds?)\b/i, gallery: 'People/Work' },
{ re: /\b(people|persons?|human(?:s|ity)?|crowds?|workers?|professions?|activities)\b/i, gallery: 'People' },
// ββ Objects ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
{ re: /\b(aircraft|airplanes?|helicopters?|gliders?|jets?|airliners?|warplanes?)\b/i, gallery: 'Objects/Vehicles/Air transport' },
{ re: /\b(cars?|automobiles?|trucks?|buses|motorcycles|vehicles?|locomotives?|trains?|railways?|trams|metros?)\b/i, gallery: 'Objects/Vehicles/Land vehicles' },
{ re: /\b(ships?|boats?|vessels?|sailing|yachts?|submarines?|maritime)\b/i, gallery: 'Objects/Vehicles/Water transport' },
{ re: /\b(food|foods?|cuisine|dishes|meals?|beverages?|fruits?|vegetables?|baking|cooking)\b/i, gallery: 'Food and drink' },
{ re: /\b(weapons?|firearms?|guns?|rifles?|swords?|knives|cannons?|missiles?)\b/i, gallery: 'Objects' },
{ re: /\b(coins?|stamps?|currencies|banknotes?|medals?|jewell?ery|watches?)\b/i, gallery: 'Objects/Monetary items' },
{ re: /\b(tools?|machinery|instruments?|electronics|computers?|cameras?|telescopes?)\b/i, gallery: 'Objects' },
{ re: /\b(toys?|dolls?|games?|board games?|musical instruments?)\b/i, gallery: 'Objects' },
{ re: /\b(textiles?|clothing|costumes?|fashion|jewell?ery|crafts?)\b/i, gallery: 'Objects' },
// ββ Historical / Non-photographic βββββββββββββββββββββββββββββββ
{ re: /\b(paintings?|portraits|frescoes?|murals?)\b/i, gallery: 'Non-photographic media/Others' },
{ re: /\b(drawings?|sketches|illustrations?|engravings?|lithographs?|etchings?|prints?|woodcuts?)\b/i, gallery: 'Non-photographic media/Printed' },
{ re: /\b(sculptures?|statues?|reliefs?)\b/i, gallery: 'Objects/Sculptures' },
{ re: /\b(maps?|cartography|atlas(?:es)?|charts?)\b/i, gallery: 'Non-photographic media/Maps' },
{ re: /\b(diagrams?|schematics?|charts?|graphs?|flowcharts?)\b/i, gallery: 'Non-photographic media/Science' },
{ re: /\b(coats? of arms|heraldry|flags?|emblems?|logos?)\b/i, gallery: 'Non-photographic media/Others' },
{ re: /\b(historical (?:photos?|images?)|19th[- ]century|18th[- ]century|17th[- ]century|daguerreotypes?|pre[- ]?1900)\b/i, gallery: 'Historical' },
// ββ Microscopy ββββββββββββββββββββββββββββββββββββββββββββββββββ
{ re: /\b(microscopy|microscope|micrograph|electron microscopy|sem image|tem image|x[- ]ray|crystallography|histology)\b/i, gallery: 'Non-photographic media/Science' }
];
function suggestGallery( cats, wt ) {
if ( !cats || !cats.length ) return null;
var joined = cats.join( ' Β· ' );
for ( var i = 0; i < FP_GALLERY_RULES.length; i++ ) {
if ( FP_GALLERY_RULES[ i ].re.test( joined ) ) return FP_GALLERY_RULES[ i ].gallery;
}
return null;
}
// βββ Shared infrastructure (QIC/VIC + light readiness) ββββββββββββββββββ
// Lightweight, self-contained readiness checks for the FP nominate dialog.
// The full image-quality engine (camera DB, diffraction, sharpness, crop
// heuristics) lives in fpc-archiver.js and is deliberately NOT duplicated
// here: those heuristics need ground truth and have caused false-positive
// friction with nominators. We surface only objective, hard-rule signals
// from imageinfo. The MediaInfo-caption and depicts checks are appended by
// the caller (fetchPreSubmitReadiness).
function runLightChecks( ii ) {
var checks = [];
var w = ii.width || 0, h = ii.height || 0;
var mp = ( w * h ) / 1e6;
if ( w && h && mp < 2 ) {
checks.push( {
name: 'Below FP resolution minimum',
pass: false,
note: 'Image is ' + mp.toFixed( 1 ) + ' MP (' + w + 'Γ' + h + ' px). ' +
'Featured pictures normally need at least 2 MP unless there is a special ' +
'reason (e.g. a rare historical image, or an SVG).'
} );
}
return checks;
}
// Insert a file-page action link, in BOTH #filetoc and the cactions portlet
// (so it shows regardless of skin/gadget conflicts). Returns the primary
// clickable <a>, or null if there is nowhere to place it.
// #filetoc is a plain <ul> in this skin: MediaWiki no longer lays its items
// out inline, so every gadget that appends to it adds another bullet on
// another line. Four one-word actions became a four-line list above the
// photograph. They are a row of actions, so they are laid out as one,
// and the row covers whatever other gadgets put there too, which is the
// point: they belong together.
var filetocStyled = false;
function styleFileToc() {
if ( filetocStyled || !document.getElementById( 'filetoc' ) ) {
return;
}
filetocStyled = true;
mw.util.addCSS(
'#filetoc{display:flex;flex-wrap:wrap;align-items:center;' +
'gap:.25em 1.5em;list-style:none;margin-left:0;padding-left:0}' +
'#filetoc>li{display:inline-block;list-style:none;margin:0}' +
'#filetoc>li::marker{content:none}'
);
}
function addFileActionLink( id, label, title ) {
var primary = null;
var fileToc = document.getElementById( 'filetoc' );
if ( fileToc ) {
styleFileToc();
var li = document.createElement( 'li' );
li.id = id;
var a = document.createElement( 'a' );
a.href = '#';
a.textContent = label;
a.title = title;
li.appendChild( a );
fileToc.appendChild( li );
primary = a;
}
var portlet = mw.util.addPortletLink( 'p-cactions', '#', label, id + '-portlet', title )
|| mw.util.addPortletLink( 'p-views', '#', label, id + '-portlet', title );
if ( portlet ) {
if ( !primary ) {
primary = portlet;
} else {
portlet.addEventListener( 'click', function ( e ) { e.preventDefault(); primary.click(); } );
}
}
return primary;
}
// Build a centred modal reusing the FP nominate dialog's chrome. Returns
// { overlay, dialog, close, body, footer }. Esc / Γ / Cancel dismiss it.
function buildModal( titleHtml ) {
var overlay = document.createElement( 'div' );
overlay.className = 'fpc-archiver-nominate-overlay';
var dialog = document.createElement( 'div' );
dialog.className = 'fpc-archiver-nominate-dialog cn-compact-dialog';
dialog.innerHTML =
'<div class="fpc-nominate-header">' +
' <div class="fpc-nominate-title">' + titleHtml + '</div>' +
' <button type="button" class="fpc-nominate-close" title="Close (Esc)">Γ</button>' +
'</div>' +
'<div class="fpc-nominate-body cn-compact-body"></div>' +
'<div class="fpc-nominate-footer">' +
' <div class="fpc-nominate-status"></div>' +
' <div class="fpc-nominate-actions">' +
' <button class="fpc-nominate-cancel">Cancel</button>' +
' <button class="fpc-nominate-submit"><b>Submit nomination</b></button>' +
' </div>' +
'</div>';
overlay.appendChild( dialog );
document.body.appendChild( overlay );
function close() {
document.removeEventListener( 'keydown', escHandler );
overlay.remove();
}
var escHandler = function ( e ) { if ( e.key === 'Escape' ) close(); };
document.addEventListener( 'keydown', escHandler );
dialog.querySelector( '.fpc-nominate-close' ).addEventListener( 'click', close );
dialog.querySelector( '.fpc-nominate-cancel' ).addEventListener( 'click', close );
return {
overlay: overlay,
dialog: dialog,
close: close,
body: dialog.querySelector( '.cn-compact-body' ),
status: dialog.querySelector( '.fpc-nominate-status' ),
submitBtn: dialog.querySelector( '.fpc-nominate-submit' ),
cancelBtn: dialog.querySelector( '.fpc-nominate-cancel' )
};
}
// Generic single-revision page read. Returns { wt, baserevid, basetimestamp,
// missing }. REJECTS on a genuine read failure so callers do NOT silently
// treat a transient API error as "page empty / not nominated".
function fetchPage( title, api ) {
return api.get( {
action: 'query', titles: title,
prop: 'revisions', rvprop: 'content|timestamp|ids', rvslots: 'main',
format: 'json', formatversion: 2
} ).then( function ( res ) {
var p = ( res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
if ( p.missing ) return { wt: '', baserevid: 0, basetimestamp: null, missing: true };
var rev = p.revisions && p.revisions[ 0 ];
if ( !rev || !rev.slots || !rev.slots.main ) {
throw new Error( 'Could not read [[' + title + ']].' );
}
return {
wt: rev.slots.main.content || '',
baserevid: rev.revid,
basetimestamp: rev.timestamp,
missing: false
};
} );
}
// Extract positional parameter 1 of the first \u007b{/Xxx|β¦|β¦}} template on a
// line, honouring \u007b{β¦}} and [[β¦]] nesting (so a [[User:X|X]] pipe inside the
// parameter is NOT mistaken for the parameter separator). Used to read the
// NOMINATOR signature (param 1) without counting reviewer sigs (param 2).
function firstTemplateParam1( s ) {
var start = s.indexOf( '\u007b{/' );
if ( start < 0 ) return '';
var brace = 0, bracket = 0, i = start, firstPipe = -1;
for ( ; i < s.length; i++ ) {
var two = s.substr( i, 2 );
if ( two === '\u007b{' ) { brace++; i++; continue; }
if ( two === '}}' ) { brace--; i++; if ( brace === 0 ) break; continue; }
if ( two === '[[' ) { bracket++; i++; continue; }
if ( two === ']]' ) { if ( bracket > 0 ) bracket--; i++; continue; }
if ( brace === 1 && bracket === 0 && s.charAt( i ) === '|' ) {
if ( firstPipe < 0 ) { firstPipe = i; }
else { return s.slice( firstPipe + 1, i ); }
}
}
return firstPipe >= 0 ? s.slice( firstPipe + 1, i ) : '';
}
// True if the given wikitext fragment links to [[User:<canon>]] (any
// talk/user variant). Used to attribute a nomination to its signer.
function fragmentHasUser( frag, canon ) {
var re = /\[\[\s*:?\s*User(?:[ _]talk)?\s*:\s*([^\]|]+?)\s*(?:\|[^\]]*)?\]\]/gi;
var m;
while ( ( m = re.exec( frag ) ) !== null ) {
if ( canonUser( m[ 1 ].replace( /[#/].*$/, '' ).trim() ) === canon ) return true;
}
return false;
}
// Make a user free-text field safe to interpolate into a wiki template
// parameter. Returns { ok, value, error }. It does NOT blindly entity-encode
// braces (VIC scopes legitimately contain [[links]] and \u007b{c|β¦}} templates);
// instead it REQUIRES the field to be brace/bracket balanced, so it can
// never close the surrounding template early or leak the rest of the page,
// and converts only TOP-LEVEL pipes to \u007b{!}} (pipes inside [[β¦]] / \u007b{β¦}}
// are left intact). Unbalanced input is rejected so the caller can ask the
// user to fix it rather than silently writing corrupt wikitext to a shared
// page (which would also break the param-1 nominator-sig parsing the QIC
// daily-cap relies on).
function sanitizeWikiField( s ) {
var v = ( s || '' ).replace( /[\r\n]+/g, ' ' ).replace( /\s+/g, ' ' ).trim();
var brace = 0, bracket = 0, out = '';
for ( var i = 0; i < v.length; i++ ) {
var two = v.substr( i, 2 );
if ( two === '\u007b{' ) { brace++; out += two; i++; continue; }
if ( two === '}}' ) { brace--; if ( brace < 0 ) return { ok: false, error: 'an unbalanced "}}"' }; out += two; i++; continue; }
if ( two === '[[' ) { bracket++; out += two; i++; continue; }
if ( two === ']]' ) { bracket--; if ( bracket < 0 ) return { ok: false, error: 'an unbalanced "]]"' }; out += two; i++; continue; }
if ( v.charAt( i ) === '|' && brace === 0 && bracket === 0 ) { out += '\u007b{!}}'; continue; }
out += v.charAt( i );
}
if ( brace !== 0 ) return { ok: false, error: 'an unbalanced "\u007b{"' };
if ( bracket !== 0 ) return { ok: false, error: 'an unbalanced "[["' };
return { ok: true, value: out };
}
// βββ Featured Picture nomination (moved verbatim from fpc-archiver.js) βββ
// βββ 2-active-nomination limit enforcement on subpage creation βββββββββββ
// Per FPC rule 11: a nominator may not have more than 2 open nominations
// at once. When the user starts creating a new FPC subpage and already has
// β₯2 active noms not near closure, show a warning and disable the save
// button (with explicit override).
function checkActiveNomLimitOnEdit( api ) {
var pageName = ( mw.config.get( 'wgPageName' ) || '' ).replace( /_/g, ' ' );
var action = mw.config.get( 'wgAction' );
var articleId = mw.config.get( 'wgArticleId' );
var user = mw.config.get( 'wgUserName' );
if ( !user ) return;
if ( action !== 'edit' && action !== 'submit' ) return;
// Only on FPC nomination subpages (File:, Set/, removal/)
if ( !/^Commons:Featured picture candidates\/(File:|Set\/|removal\/)/.test( pageName ) ) return;
// Only when creating a NEW page (articleId 0 = page doesn't exist yet)
if ( articleId !== 0 ) return;
fetchActiveNomsByUser( user, api ).then( function ( noms ) {
// Consider only nominations < 7 days old as "not close to closing"
// (last 2 days of the 9-day window count as near closure).
var stillFar = noms.filter( function ( n ) { return n.daysOld < 7; } );
if ( stillFar.length < 2 ) return;
renderActiveNomLimitBanner( user, stillFar );
} ).catch( function ( err ) {
// Do NOT silently swallow a read failure. That would look like
// "0 active nominations" and let the user save a 3rd concurrent
// nomination past rule 11. Make the failure visible so they can
// re-check manually before saving.
try { console.warn( '[commons-nominator] rule-11 precheck read failed:', err ); } catch ( e ) {}
mw.notify( 'Could not verify your active FPC nominations (read error). ' +
'Please re-check rule 11 (max 2 active nominations) before saving.',
{ type: 'warn', autoHide: false } );
} );
}
function fetchActiveNomsByUser( username, api ) {
return api.get( {
action: 'query', titles: 'Commons:Featured picture candidates/candidate list',
prop: 'revisions', rvprop: 'content', rvslots: 'main',
formatversion: 2
} ).then( function ( res ) {
var page = ( res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
if ( !page.revisions ) return [];
var wt = page.revisions[ 0 ].slots.main.content || '';
var transclusions = wt.match( /\{\{(Commons:Featured picture candidates\/[^|}\n]+)\}\}/g ) || [];
var titles = transclusions.map( function ( m ) {
return m.slice( 2, -2 ).trim();
} );
if ( !titles.length ) return [];
// Batch-fetch subpages
var batches = [];
for ( var i = 0; i < titles.length; i += 40 ) batches.push( titles.slice( i, i + 40 ) );
return Promise.all( batches.map( function ( b ) {
return api.get( {
action: 'query', titles: b.join( '|' ),
prop: 'revisions', rvprop: 'content', rvslots: 'main',
formatversion: 2
} );
} ) ).then( function ( results ) {
var matched = [];
results.forEach( function ( res ) {
( res.query && res.query.pages || [] ).forEach( function ( p ) {
if ( p.missing || !p.revisions ) return;
var wt = p.revisions[ 0 ].slots.main.content || '';
var nominator = detectUploader( wt );
if ( !nominator ) return;
// Rule 11 counts subpages OWNED by the user (the user who
// created them = the nominator), not files they uploaded
// or created. The nominator is the user whose signature
// sits closest to the first UTC timestamp in the wikitext.
if ( canonUser( nominator ) !== canonUser( username ) ) return;
// Skip nominations that are effectively done but still
// transcluded in candidate_list because FPCBot hasn't
// archived them yet (the bot polls every ~30 min, so
// there is always a window where closed noms sit here).
// Any of the following means the slot is no longer
// "spent" against the nominator's rule-11 budget:
// - \u007b{FPC-results-reviewed}} closer wrote a verdict
// - \u007b{Withdraw}} nominator pulled out
// - \u007b{FPX|...}} 5th-day-rule fast-fail
// - \u007b{FPD|...}} disqualified (rule 11 etc.)
if ( /\{\{\s*FPC-results-reviewed\b/i.test( wt ) ) return;
if ( /\{\{\s*Withdraw\s*[}|]/i.test( wt ) ) return;
if ( /\{\{\s*FPX\s*[}|]/i.test( wt ) ) return;
if ( /\{\{\s*FPD\s*[}|]/i.test( wt ) ) return;
// Detect nomination date, the first UTC timestamp in wikitext.
var ts = firstUtcTimestamp( wt );
if ( !ts ) return;
var daysOld = ( Date.now() - ts.getTime() ) / 86400000;
matched.push( { title: p.title, daysOld: daysOld } );
} );
} );
return matched;
} );
} );
// NOTE: deliberately NOT catching here. A transient read failure must
// REJECT so the click-time precheck (Promise.all + catch) surfaces a
// "retry" message instead of silently treating the error as "0 active
// nominations" and letting the user nominate past the rule-11 cap.
}
function firstUtcTimestamp( wt ) {
var months = { January: 0, February: 1, March: 2, April: 3, May: 4, June: 5,
July: 6, August: 7, September: 8, October: 9, November: 10, December: 11 };
var m = wt.match( /(\d{1,2}):(\d{2}),\s+(\d{1,2})\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{4})\s*\(UTC\)/ );
if ( !m ) return null;
return new Date( Date.UTC( parseInt( m[ 5 ], 10 ), months[ m[ 4 ] ], parseInt( m[ 3 ], 10 ),
parseInt( m[ 1 ], 10 ), parseInt( m[ 2 ], 10 ) ) );
}
function renderActiveNomLimitBanner( user, activeNoms ) {
var banner = document.createElement( 'div' );
banner.className = 'fpc-archiver-2nom-banner';
var listHtml = activeNoms.map( function ( n ) {
var rem = ( 9 - n.daysOld ).toFixed( 1 );
var name = n.title.replace( /^Commons:Featured picture candidates\//, '' );
return '<li><a href="/wiki/' + encodeURIComponent( n.title ) + '" target="_blank">' +
escapeAttr( name ) + '</a> with <b>' + rem + ' days remaining</b></li>';
} ).join( '' );
banner.innerHTML =
'<div class="fpc-archiver-2nom-head">β FPC two-active-nomination limit (rule 11)</div>' +
'<div>You already have <b>' + activeNoms.length + '</b> active nominations not yet near closure:</div>' +
'<ul>' + listHtml + '</ul>' +
'<div>Per <a href="https://commons.wikimedia.org/wiki/Commons:Featured_picture_candidates/rules#General_rules" target="_blank">rule 11</a>, ' +
'please wait until one closes before publishing another. ' +
'Saving has been disabled. <button type="button" class="fpc-archiver-2nom-override">I understand, let me save anyway</button></div>';
// Insert at the top of the editor area
var host = document.getElementById( 'mw-content-text' ) || document.body;
host.insertBefore( banner, host.firstChild );
// Disable save buttons (both old-skin and 2010+ wikieditor variants)
var saveBtns = [];
[ '#wpSave', 'button[name="wpSave"]', '#wpSaveWidget button', '.ve-ui-toolbar-saveButton button' ]
.forEach( function ( sel ) {
document.querySelectorAll( sel ).forEach( function ( b ) { saveBtns.push( b ); } );
} );
saveBtns.forEach( function ( b ) {
b.disabled = true;
b.dataset.fpcLockedReason = 'fpc-archiver: 2-active-nomination limit';
b.title = 'Disabled by fpc-archiver. See the banner above.';
} );
// Intercept form submit at the capture phase as a hard block
var form = document.getElementById( 'editform' );
function blocker( e ) {
if ( banner._unlocked ) return;
e.preventDefault();
e.stopImmediatePropagation();
window.scrollTo( { top: 0, behavior: 'smooth' } );
mw.notify( 'Save blocked: you already have 2 active FPC nominations (rule 11). Use the override button if you really want to proceed.', { type: 'error', autoHide: false } );
}
if ( form ) form.addEventListener( 'submit', blocker, true );
banner.querySelector( '.fpc-archiver-2nom-override' ).addEventListener( 'click', function () {
banner._unlocked = true;
saveBtns.forEach( function ( b ) {
b.disabled = false;
b.title = '';
delete b.dataset.fpcLockedReason;
} );
if ( form ) form.removeEventListener( 'submit', blocker, true );
banner.style.opacity = '0.6';
banner.querySelector( '.fpc-archiver-2nom-override' ).disabled = true;
banner.querySelector( '.fpc-archiver-2nom-override' ).textContent = 'Override active, save now allowed';
} );
}
// βββ Nominate-for-FP button on File: pages ββββββββββββββββββββββββββββββ
// Adds a "Nominate for FP" portlet link on File: pages. Click β checks the
// user's active-nominations count (rule 11) and either blocks with a
// message or opens a small dialog to compose the nomination, then creates
// the subpage + appends the transclusion to the candidate list.
function setupNominateButton( api ) {
var _ns = mw.config.get( 'wgNamespaceNumber' );
var _page = mw.config.get( 'wgPageName' ) || '';
var _user = mw.config.get( 'wgUserName' );
try { console.log( '[fpc-archiver] setupNominateButton invoked', { ns: _ns, page: _page, user: _user, hasFiletoc: !!document.getElementById( 'filetoc' ) } ); } catch (e) {}
if ( _ns !== 6 ) return; // File: namespace only
var pageName = _page;
if ( !/^File:/.test( pageName ) ) return;
var user = _user;
if ( !user ) return;
// Skip if file is already a featured picture (has FPpromoted template),
// quick heuristic via category check would require an extra request,
// so we just allow nomination and let the wiki rules check duplicates.
// Insert the nominate link in MULTIPLE places so the user always sees
// it regardless of skin / gadget conflicts:
// 1. As an <li> inside #filetoc (the "File Β· File history Β· File
// usage on Commons Β· Metadata" bar) so it sits next to the QI
// nominator gadget link, that's where users actually look on a
// file page.
// 2. As a portlet link in p-cactions (Vector 2022 "More" dropdown,
// tab on legacy Vector) so it shows up in the skin chrome too.
// Whichever one renders first becomes the canonical `link` (the
// promise chain below updates its label/colour from QI/VI/FP state).
var link;
var TITLE_TEXT = 'π Nominate for FP';
var TITLE_DESC = 'Nominate this file for Featured Picture Candidates (rule-11 checked)';
var fileToc = document.getElementById( 'filetoc' );
if ( fileToc ) {
var li = document.createElement( 'li' );
li.id = 'pt-fpc-nominate';
var aLink = document.createElement( 'a' );
aLink.href = '#';
aLink.textContent = TITLE_TEXT;
aLink.title = TITLE_DESC;
li.appendChild( aLink );
// Append at the END so the QI nominator (which prepends) stays
// visually first, both coexist without fighting for first slot.
fileToc.appendChild( li );
link = aLink;
try { console.log( '[fpc-archiver] Nominate link added to #filetoc' ); } catch (e) {}
}
// Always also add a portlet link (different id) as a backup target,
// useful when the user's custom CSS hides #filetoc or when something
// else clears it after our insertion.
var portletLink = mw.util.addPortletLink(
'p-cactions', '#', TITLE_TEXT,
'pt-fpc-nominate-portlet', TITLE_DESC
) || mw.util.addPortletLink(
'p-views', '#', TITLE_TEXT,
'pt-fpc-nominate-portlet', TITLE_DESC
);
if ( portletLink ) {
try { console.log( '[fpc-archiver] Nominate link added to portlet' ); } catch (e) {}
if ( !link ) link = portletLink;
// Mirror clicks from the portlet to the primary handler.
if ( link !== portletLink ) {
portletLink.addEventListener( 'click', function ( e ) {
e.preventDefault();
link.click();
} );
}
}
if ( !link ) {
try { console.warn( '[fpc-archiver] Could not insert Nominate link, no #filetoc and no portlet target' ); } catch (e) {}
return;
}
// Both the QI/VI assessment check AND the "is this file currently
// being nominated?" check fire in parallel. The link is held disabled
// (pointer-events:none + faded) until BOTH resolve so a fast click
// never opens a Nominate dialog for a file that already has an
// active nomination, the dialog would just alert "Cannot nominate"
// anyway, so the gate avoids the confusing intermediate state.
link.style.pointerEvents = 'none';
link.style.opacity = '0.6';
if ( portletLink ) { portletLink.style.pointerEvents = 'none'; portletLink.style.opacity = '0.6'; }
var assessmentsPromise = checkFileAssessments( pageName, api );
var nomCheckPromise = checkFileAlreadyNominated( pageName, api );
Promise.all( [ assessmentsPromise, nomCheckPromise ] ).then( function ( results ) {
var a = results[ 0 ];
var existingNom = results[ 1 ];
// Re-enable interaction. Specific cases below may strip the click
// handler entirely (via clone+replace), which is fine.
link.style.pointerEvents = '';
link.style.opacity = '';
if ( portletLink ) { portletLink.style.pointerEvents = ''; portletLink.style.opacity = ''; }
// Priority 1: file already has an open FPC nomination β morph the
// link into a "π Open FPC nomination" pointer at the existing
// subpage. Clone+replace strips the click handler so the dialog
// never opens; the link follows the href on normal click.
if ( existingNom ) {
var nomUrl = '/wiki/' + encodeURI( existingNom.replace( / /g, '_' ) );
function morphToOpenLink( a2 ) {
if ( !a2 ) return;
a2.textContent = 'π Open FPC nomination';
a2.title = 'This file already has an open FPC nomination: ' + existingNom;
a2.href = nomUrl;
var clone = a2.cloneNode( true );
if ( a2.parentNode ) a2.parentNode.replaceChild( clone, a2 );
}
morphToOpenLink( link );
morphToOpenLink( portletLink );
return;
}
// Priority 2: assessment styling (QI / VI / FP).
var li = link.closest( 'li' ) || link;
if ( a.isFP ) {
li.classList.add( 'fpc-nominate-already-fp', 'cn-disabled' );
link.setAttribute( 'aria-disabled', 'true' );
link.title = 'Inactive, already a Featured Picture; it can\'t be nominated again.';
} else if ( a.isQI && a.isVI ) {
li.classList.add( 'fpc-nominate-qi-vi' );
link.textContent = 'π Nominate for FP (QI + VI β)';
link.title = 'Already both Quality Image and Valued Image. Strong FP candidate.';
} else if ( a.isQI ) {
li.classList.add( 'fpc-nominate-qi' );
link.textContent = 'π Nominate for FP (QI β)';
link.title = 'Already a Quality Image. Strong FP candidate.';
} else if ( a.isVI ) {
li.classList.add( 'fpc-nominate-vi' );
link.textContent = 'π Nominate for FP (VI β)';
link.title = 'Already a Valued Image';
}
} );
link.addEventListener( 'click', function ( e ) {
e.preventDefault();
// Inactive when already a Featured Picture, bail before any
// "Checkingβ¦"/network so the link does nothing (the tooltip explains).
if ( ( link.closest( 'li' ) || link ).classList.contains( 'cn-disabled' ) ) return;
link.style.opacity = '0.5';
var oldText = link.textContent;
link.textContent = 'Checkingβ¦';
// Three preconditions, all checked in parallel:
// (1) File is not already a Featured Picture
// (2) File is not currently nominated (subpage transcluded in
// candidate list)
// (3) User has <2 active nominations near closure (rule 11)
Promise.all( [
assessmentsPromise,
checkFileAlreadyNominated( pageName, api ),
fetchActiveNomsByUser( user, api )
] ).then( function ( results ) {
link.style.opacity = '';
link.textContent = oldText;
var assess = results[ 0 ];
var existingNom = results[ 1 ];
var activeNoms = results[ 2 ];
if ( assess.isFP ) {
alert( 'Cannot nominate. This image is already a Featured Picture.\n\n' +
'Found in category: "' + assess.fpCategory + '".' );
return;
}
if ( existingNom ) {
var goto = confirm( 'Cannot nominate. This image already has an open FPC nomination:\n\n' +
existingNom + '\n\nOpen the existing nomination?' );
if ( goto ) {
window.location.href = '/wiki/' + encodeURIComponent( existingNom );
}
return;
}
var stillFar = activeNoms.filter( function ( n ) { return n.daysOld < 7; } );
if ( stillFar.length >= 2 ) {
var list = stillFar.map( function ( n ) {
return 'β’ ' + n.title.replace( /^Commons:Featured picture candidates\//, '' ) +
' (' + ( 9 - n.daysOld ).toFixed( 1 ) + ' days remaining)';
} ).join( '\n' );
alert( 'Cannot nominate. FPC rule 11 caps active nominations.\n\n' +
'You already have ' + stillFar.length + ' active FPC nominations:\n\n' +
list + '\n\nPlease wait until one closes (or is within 2 days of closing) before nominating another.' );
return;
}
showNominationDialog( pageName, user, api );
} ).catch( function ( err ) {
link.style.opacity = '';
link.textContent = oldText;
console.warn( '[fpc-archiver] nominate-precheck failed:', err );
alert( 'Failed to check nomination preconditions. Please retry in a moment.' );
} );
} );
}
// Detect Featured Picture / Quality Image / Valued Image assessments,
// primarily via the templates that the assessment processes add to the
// file's description page, with category membership as fallback. All three
// are independent tracks on Commons; a file can hold any combination.
function checkFileAssessments( filePageName, api ) {
return api.get( {
action: 'query', titles: filePageName,
prop: 'revisions|categories',
rvprop: 'content', rvslots: 'main',
cllimit: 'max',
format: 'json', formatversion: 2
} ).then( function ( res ) {
var p = ( res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
var wt = ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main && p.revisions[ 0 ].slots.main.content ) || '';
var cats = ( p.categories || [] ).map( function ( c ) { return c.title; } );
// Template-level detection (primary signal, these are placed
// directly by the assessment process).
// FP: \u007b{Featured picture}}, \u007b{FP}}, \u007b{Assessments|...featured=1...}}
// QI: \u007b{QualityImage}}, \u007b{Quality image}}
// VI: \u007b{Valued image}}, \u007b{VI}}
var fpTplRe = /\{\{\s*(?:Featured[ _]?picture|FP|Featured[ _]image|Featured[ _]media)\s*[}|]/i;
var fpAssessRe = /\{\{\s*Assessments\b[^}]*\b(?:com[-_ ]?featured|featured)\s*=\s*(?:1|yes|true)\b/i;
var qiTplRe = /\{\{\s*(?:QualityImage|Quality[ _]image)\b/i;
var viTplRe = /\{\{\s*(?:Valued[ _]image|VI)\s*[|}]/i;
// Category-level detection (fallback, covers cases where the
// bot updated the category but the descriptive template was
// already removed or not yet added)
var fpCatRe = /^Category:Featured (?:pictures? on Wikimedia Commons|pictures? by User:|pictures? of |picture(?: of the (?:day|year))?)/i;
var qiCatRe = /^Category:Quality images?\b/i;
var viCatRe = /^Category:Valued image\b/i;
var isFP = fpTplRe.test( wt ) || fpAssessRe.test( wt );
var isQI = qiTplRe.test( wt );
var isVI = viTplRe.test( wt );
var fpCat = null, qiCat = null, viCat = null;
for ( var i = 0; i < cats.length; i++ ) {
if ( !fpCat && fpCatRe.test( cats[ i ] ) ) fpCat = cats[ i ];
if ( !qiCat && qiCatRe.test( cats[ i ] ) ) qiCat = cats[ i ];
if ( !viCat && viCatRe.test( cats[ i ] ) ) viCat = cats[ i ];
}
if ( !isFP && fpCat ) isFP = true;
if ( !isQI && qiCat ) isQI = true;
if ( !isVI && viCat ) isVI = true;
return {
isFP: isFP, fpCategory: fpCat,
isQI: isQI, qiCategory: qiCat,
isVI: isVI, viCategory: viCat,
categories: cats
};
} ).catch( function () {
return { isFP: false, isQI: false, isVI: false, categories: [] };
} );
}
// Check if a File: page already has an open FPC nomination, i.e. the
// candidate-list page transcludes its candidate subpage (including any
// renomination variants like .../File:X.jpg/2).
function checkFileAlreadyNominated( filePageName, api ) {
return api.get( {
action: 'query', titles: 'Commons:Featured picture candidates/candidate list',
prop: 'revisions', rvprop: 'content', rvslots: 'main',
format: 'json', formatversion: 2
} ).then( function ( res ) {
var p = ( res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
if ( !p.revisions ) return null;
var wt = p.revisions[ 0 ].slots.main.content || '';
// MediaWiki treats '_' and ' ' as interchangeable in titles, and
// the candidate list mixes both spellings (e.g. some entries say
// ``\u007b{Commons:Featured picture candidates/β¦}}`` while others say
// ``\u007b{Commons:Featured_picture_candidates/β¦}}``, with similarly
// mixed underscores/spaces inside the file name itself). Match
// either character at every position so the precheck can't miss
// an existing nomination because of a typographic detail.
// Also accept ``/<N>`` renomination suffix.
var escaped = filePageName.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
var nameClass = escaped.replace( /[ _]/g, '[ _]' );
var re = new RegExp( '\\{\\{\\s*Commons:Featured[ _]picture[ _]candidates/' + nameClass + '(/\\d+)?\\s*\\}\\}' );
var m = wt.match( re );
if ( !m ) return null;
return 'Commons:Featured picture candidates/' + filePageName.replace( /_/g, ' ' ) + ( m[ 1 ] || '' );
} ).catch( function () { return null; } );
}
function showNominationDialog( filePageName, user, api ) {
var fileTitle = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
// Declared at the dialog-host scope (not inside the .then callback
// below) so the submit click handler, which lives inside renderDialog
// and fires much later, can close over it. Assigned once the author
// fetch resolves.
var authorParsed = null;
var originalUploader = null;
// Fetch original uploader, topical categories, author (from the
// Information template's |Author= line), and the FP gallery icon map
// (also acts as the catalog of every known leaf gallery name) in
// parallel. The icon-map is shared cache so we don\'t re-fetch it on
// every Nominate click.
Promise.all( [
fetchOriginalUploader( filePageName, api ),
fetchTopicalCategories( filePageName, api ),
fetchAuthorFromInformation( filePageName, api ),
loadFpgIconMap( api )
] ).then( function ( results ) {
originalUploader = results[ 0 ];
var cats = results[ 1 ];
authorParsed = results[ 2 ];
var iconMap = results[ 3 ];
var needsNotify = originalUploader && originalUploader !== user;
// Smart suggestion: walks category tree up to 2 hops looking for
// any path leaf name that matches a known FP gallery. Falls back
// to the rule-based suggestGallery() heuristic when no taxonomy
// match lands.
return smartSuggestGallery( cats, iconMap, api ).then( function ( smart ) {
var suggested = smart || suggestGallery( cats, '' );
// Only pre-fill when the suggested gallery actually exists on
// Commons, avoids the embarrassment of pre-filling a
// redlinked path that the user would then have to debug.
if ( !suggested ) {
renderDialog( originalUploader, needsNotify, null );
return;
}
return new Promise( function ( resolve ) {
api.get( {
action: 'query',
titles: 'Commons:Featured pictures/' + suggested,
prop: 'info',
format: 'json', formatversion: 2
} ).done( function ( r ) {
var pp = ( r && r.query && r.query.pages && r.query.pages[ 0 ] ) || {};
renderDialog( originalUploader, needsNotify, pp.missing ? null : suggested );
resolve();
} ).fail( function () {
renderDialog( originalUploader, needsNotify, null );
resolve();
} );
} );
} );
} );
function renderDialog( originalUploader, needsNotify, suggestedGallery ) {
var overlay = document.createElement( 'div' );
overlay.className = 'fpc-archiver-nominate-overlay';
var dialog = document.createElement( 'div' );
dialog.className = 'fpc-archiver-nominate-dialog';
var notifyHtml = needsNotify
? '<label class="fpc-nominate-option"><input type="checkbox" class="fpc-nominate-notify" checked> ' +
'Notify <b>' + escapeAttr( originalUploader ) + '</b> on their talk page (in English)</label>'
: '';
dialog.innerHTML =
// Header: single compact line, title + close button.
'<div class="fpc-nominate-header">' +
' <div class="fpc-nominate-title">Nominate <i>' + escapeAttr( fileTitle ) + '</i> for Featured Picture</div>' +
' <button type="button" class="fpc-nominate-close" title="Close (Esc)">Γ</button>' +
'</div>' +
// Body: two-column layout. Left = form, right = readiness panel.
'<div class="fpc-nominate-body">' +
' <div class="fpc-nominate-col-form">' +
// Gallery
' <section class="fpc-nominate-section">' +
' <div class="fpc-nominate-section-title">Gallery</div>' +
' <div class="fpc-nominate-gallery-wrap">' +
' <input type="text" class="fpc-nominate-gallery" autocomplete="off" spellcheck="false" placeholder="Type to search or pick from the tree below e.g. Plants/Asterales" value="' + ( suggestedGallery ? escapeAttr( suggestedGallery ) : '' ) + '">' +
' <div class="fpc-nominate-gallery-suggest" hidden></div>' +
' <div class="fpc-nominate-gallery-tree"></div>' +
' </div>' +
( suggestedGallery
? ' <div class="fpc-nominate-gallery-hint">π Pre-filled from this file\'s categories, verify it matches your subject.</div>'
: '' ) +
' </section>' +
// Pitch
' <section class="fpc-nominate-section">' +
' <div class="fpc-nominate-section-title">Pitch <span class="fpc-nominate-section-hint">(optional)</span></div>' +
' <textarea class="fpc-nominate-comment" placeholder="What makes this image special? Anything reviewers should know."></textarea>' +
' </section>' +
// Options
' <section class="fpc-nominate-section">' +
' <div class="fpc-nominate-options">' +
' <label class="fpc-nominate-option"><input type="checkbox" class="fpc-nominate-selfsupport" checked> ' +
' Add my <code>\u007b{Support}}</code> vote automatically</label>' +
' ' + notifyHtml +
' </div>' +
' </section>' +
' </div>' + // /fpc-nominate-col-form
// Right column: gallery samples (top) + readiness panel (bottom).
' <aside class="fpc-nominate-col-readiness">' +
' <div class="fpc-nominate-samples-section">' +
' <div class="fpc-nominate-section-title">Samples of this category</div>' +
' <div class="fpc-nominate-samples-body">Pick a gallery on the left to see samples.</div>' +
' </div>' +
' <div class="fpc-nominate-readiness-section">' +
' <div class="fpc-nominate-section-title">Readiness check</div>' +
' <div class="fpc-nominate-readiness-body">π Running checksβ¦</div>' +
' </div>' +
' </aside>' +
'</div>' + // /fpc-nominate-body
// Footer: sticky action bar.
'<div class="fpc-nominate-footer">' +
' <div class="fpc-nominate-status"></div>' +
' <div class="fpc-nominate-actions">' +
' <button class="fpc-nominate-cancel">Cancel</button>' +
' <button class="fpc-nominate-submit"><b>Submit nomination</b></button>' +
' </div>' +
'</div>';
overlay.appendChild( dialog );
document.body.appendChild( overlay );
var galleryInput = dialog.querySelector( '.fpc-nominate-gallery' );
var commentInput = dialog.querySelector( '.fpc-nominate-comment' );
var supportCb = dialog.querySelector( '.fpc-nominate-selfsupport' );
var notifyCb = dialog.querySelector( '.fpc-nominate-notify' );
var statusEl = dialog.querySelector( '.fpc-nominate-status' );
var submitBtn = dialog.querySelector( '.fpc-nominate-submit' );
var cancelBtn = dialog.querySelector( '.fpc-nominate-cancel' );
cancelBtn.addEventListener( 'click', function () { overlay.remove(); } );
var closeBtn = dialog.querySelector( '.fpc-nominate-close' );
if ( closeBtn ) closeBtn.addEventListener( 'click', function () { overlay.remove(); } );
// Click-outside-to-close removed: too easy to wipe the form by
// accident. Use Γ, Cancel, or Esc to dismiss.
var escHandler = function ( e ) { if ( e.key === 'Escape' ) overlay.remove(); };
document.addEventListener( 'keydown', escHandler );
// Cleanup the listener when the overlay is removed so it doesn't
// pile up across nominate sessions.
new MutationObserver( function ( muts, obs ) {
if ( !document.body.contains( overlay ) ) {
document.removeEventListener( 'keydown', escHandler );
obs.disconnect();
}
} ).observe( document.body, { childList: true, subtree: false } );
// Sample-thumbnails state. Declared BEFORE setupGalleryTreeBrowser
// because the tree\'s onNavigate callback fires synchronously
// during setup, and would read samplesBody as undefined if the
// ref were assigned after the call.
var samplesBody = dialog.querySelector( '.fpc-nominate-samples-body' );
var samplesTimer = null;
var samplesActivePath = null; // most-recent requested path, to ignore stale responses
function showSamplesFor( rawPath ) {
var parts = ( rawPath || '' ).split( '#' );
var path = parts[ 0 ].trim();
var section = parts[ 1 ] ? parts[ 1 ].trim() : null;
// samplesActivePath is keyed by the FULL identifier (path +
// optional section) so picking a section on the same page
// doesn\'t race against the whole-page fetch.
var key = path + ( section ? '#' + section : '' );
samplesActivePath = key;
if ( !samplesBody ) return; // defensive: dialog DOM not ready
if ( !path ) {
samplesBody.textContent = 'Pick a gallery on the left to see samples.';
return;
}
samplesBody.textContent = 'π Loading samplesβ¦';
fetchGallerySamples( path, section, api ).then( function ( samples ) {
if ( samplesActivePath !== key ) return; // user moved on
renderGallerySamples( samples, samplesBody, path, section );
} ).catch( function () {
if ( samplesActivePath !== key ) return;
samplesBody.textContent = 'Could not load samples for this gallery.';
} );
}
function refreshSamplesFromInput() { showSamplesFor( galleryInput.value ); }
galleryInput.addEventListener( 'change', refreshSamplesFromInput );
galleryInput.addEventListener( 'input', function () {
clearTimeout( samplesTimer );
samplesTimer = setTimeout( refreshSamplesFromInput, 500 );
} );
// Hook up the gallery autocomplete (live prefix-search against
// existing Commons:Featured pictures/* subpages so the user can't
// submit a redlink). Bound only after dialog mounts because we
// need both the input and its suggest container in the DOM.
setupGalleryAutocomplete( galleryInput, dialog.querySelector( '.fpc-nominate-gallery-suggest' ), api );
// In-dialog gallery navigator. Always visible, autocomplete and
// tree-pick are complementary, no toggle. Starts at the parent of
// the pre-filled suggestion (or root) so siblings are visible.
setupGalleryTreeBrowser(
dialog.querySelector( '.fpc-nominate-gallery-tree' ),
dialog.querySelector( '.fpc-nominate-gallery-suggest' ),
galleryInput,
api,
function ( navigatedPath ) {
// Whenever the user drills into a tree node (without
// committing it to the input yet), refresh the samples
// for that level so the right column shows what kind of
// images live there. Empty path = root β show "pick one".
showSamplesFor( navigatedPath );
}
);
refreshSamplesFromInput();
// Kick off the readiness check asynchronously so the dialog
// renders immediately. Submit stays enabled regardless of the
// result, the panel is informational, not a blocker.
var readinessBody = dialog.querySelector( '.fpc-nominate-readiness-body' );
fetchPreSubmitReadiness( filePageName, api ).then( function ( data ) {
renderPreSubmitReadiness( data, readinessBody );
} ).catch( function ( err ) {
readinessBody.innerHTML = '<span style="color:#9ca3af">Could not run readiness check (' +
escapeAttr( ( err && err.message ) || 'unknown error' ) + '). Submitting is still safe.</span>';
} );
submitBtn.addEventListener( 'click', function () {
var gallery = galleryInput.value.trim().replace( /_/g, ' ' ).replace( /\s+/g, ' ' );
var comment = commentInput.value.trim();
var selfSupport = supportCb.checked;
var notifyUploader = notifyCb && notifyCb.checked && needsNotify;
submitBtn.disabled = true;
cancelBtn.disabled = true;
statusEl.textContent = 'Creating nomination subpageβ¦';
var attribution = buildAttributionLine( authorParsed, originalUploader, user );
submitNomination( filePageName, fileTitle, user, gallery, comment, selfSupport, attribution, api )
.then( function ( subpageTitle ) {
statusEl.textContent = 'Adding to candidate listβ¦';
return addTransclusionToCandidateList( subpageTitle, api )
.then( function () { return subpageTitle; } );
} )
.then( function ( subpageTitle ) {
if ( notifyUploader ) {
statusEl.textContent = 'Notifying original uploaderβ¦';
return postTalkNotification( originalUploader, filePageName, fileTitle, subpageTitle, api )
.then( function () { return subpageTitle; } )
// Don't fail the nomination if the talk-page
// notification post fails (e.g., user has
// protected talk page).
.catch( function () { return subpageTitle; } );
}
return subpageTitle;
} )
.then( function ( subpageTitle ) {
statusEl.textContent = 'Done! Opening your nominationβ¦';
setTimeout( function () {
window.location.href = '/wiki/' + encodeURIComponent( subpageTitle );
}, 1000 );
} )
.catch( function ( err ) {
var msg = err && err.error ? ( err.error.code + ': ' + err.error.info ) : ( err && err.message ? err.message : String( err ) );
statusEl.innerHTML = '<span style="color:#b91c1c"><b>Error:</b> ' + escapeAttr( msg ) + '</span>';
submitBtn.disabled = false;
cancelBtn.disabled = false;
} );
} );
}
}
// Returns the username of the user who first uploaded the file (the oldest
// imageinfo revision). Useful for the courtesy talk-page notification when
// the nominator differs from the original uploader.
function fetchOriginalUploader( filePageName, api ) {
return api.get( {
action: 'query', titles: filePageName,
prop: 'imageinfo',
iiprop: 'user|timestamp',
iilimit: 'max',
format: 'json', formatversion: 2
} ).then( function ( res ) {
var p = ( res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
var revs = p.imageinfo || [];
if ( !revs.length ) return null;
return revs[ revs.length - 1 ].user || null;
} ).catch( function () { return null; } );
}
// Topical categories of the file (excluding hidden meta cats). Used by
// the nomination dialog to pre-suggest a gallery via suggestGallery().
function fetchTopicalCategories( filePageName, api ) {
return api.get( {
action: 'query', titles: filePageName,
prop: 'categories', cllimit: 'max', clshow: '!hidden',
format: 'json', formatversion: 2
} ).then( function ( res ) {
var p = ( res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
return ( p.categories || [] ).map( function ( c ) { return c.title.replace( /^Category:/, '' ); } );
} ).catch( function () { return []; } );
}
// Extract the |Author= line from the file's Information / Artwork / Art
// Photo template, plus any username embedded in it (the typical patterns
// are [[User:X|β¦]], \u007b{u|X}}, \u007b{User:X}}, or a Special:Contributions link).
// Falls back to the raw text when no username can be identified, callers
// that want to dedupe against the uploader/nominator should only treat
// the username as identity-comparable.
function fetchAuthorFromInformation( filePageName, api ) {
return api.get( {
action: 'query', titles: filePageName,
prop: 'revisions', rvprop: 'content', rvslots: 'main',
format: 'json', formatversion: 2
} ).then( function ( res ) {
var p = ( res && res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
var wt = ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main && p.revisions[ 0 ].slots.main.content ) || '';
return parseAuthorField( wt );
} ).catch( function () { return { raw: null, username: null }; } );
}
function parseAuthorField( wt ) {
// Match |Author= or |author= or |artist= or |photographer=. The value
// can span the rest of the line; stop at the next top-level pipe (i.e.
// the next "|" not nested inside \u007b{β¦}}). Brace-counting is simpler than
// a non-greedy regex and avoids accidentally grabbing later fields.
var labelRe = /\|\s*(?:[Aa]uthor|[Aa]rtist|[Pp]hotographer)\s*=\s*/g;
var labelMatch = labelRe.exec( wt );
if ( !labelMatch ) return { raw: null, username: null };
var start = labelMatch.index + labelMatch[ 0 ].length;
var depth = 0;
var end = wt.length;
for ( var i = start; i < wt.length; i++ ) {
var ch = wt[ i ];
if ( ch === '{' && wt[ i + 1 ] === '{' ) { depth++; i++; continue; }
// A closer while depth is already 0 is the END of the enclosing
// template, not a nested one. Letting depth go negative made the
// value run on until some later "\u007b{" brought it back to 0, so an
// Author field that is the last parameter swallowed the rest of the
// page into the nomination's attribution sentence.
if ( ch === '}' && wt[ i + 1 ] === '}' ) { if ( depth === 0 ) { end = i; break; } depth--; i++; continue; }
if ( ch === '[' && wt[ i + 1 ] === '[' ) { depth++; i++; continue; }
if ( ch === ']' && wt[ i + 1 ] === ']' ) { if ( depth === 0 ) { end = i; break; } depth--; i++; continue; }
if ( depth === 0 && ( ch === '|' || ch === '\n' ) ) {
end = i;
// Allow the value to continue across a newline if the next
// non-whitespace char is not a new pipe (covers multi-line
// author fields). Single-line is fine, so break.
if ( ch === '\n' ) {
var rest = wt.slice( i + 1 ).match( /^\s*/ );
var afterWs = i + 1 + ( rest ? rest[ 0 ].length : 0 );
if ( wt[ afterWs ] === '|' || wt[ afterWs ] === '}' ) break;
continue;
}
break;
}
}
var raw = wt.slice( start, end ).trim();
if ( !raw ) return { raw: null, username: null };
// Search for an embedded username.
var u = raw.match( /\[\[\s*User\s*:\s*([^|\]\n]+?)\s*[\|\]]/i ) ||
raw.match( /\{\{\s*(?:u|user|User|U)\s*\|\s*([^|}]+?)\s*[\|}]/i ) ||
raw.match( /\{\{\s*User\s*:\s*([^|}\s]+?)\s*[\|}]/i ) ||
raw.match( /Special:Contributions\s*\/\s*([^|\]\s]+)/i );
var username = u ? u[ 1 ].trim() : null;
// \u007b{Creator:<full name>}} templates resolve to a wiki user via the
// creator\'s Linkback / Wikidata "alternative name" mapping. We can\'t
// resolve them generically from the client without an extra API call,
// but we DO recognize the script-owner\'s own Creator template by
// name so files Wilfredor uploaded with the canonical
// \u007b{Creator:Wilfredo Rafael Rodriguez Hernandez}} dedupe to himself
// for the attribution sentence. Other users adding their own Creator
// template should add their alias here.
if ( !username ) {
var creator = raw.match( /\{\{\s*Creator\s*:\s*([^|}]+?)\s*[\|}]/i );
if ( creator ) {
var name = creator[ 1 ].trim();
if ( /^Wilfredo\s+Rafael\s+Rodriguez\s+Hernandez$/i.test( name ) ) {
username = 'Wilfredor';
}
}
}
return { raw: raw, username: username };
}
// Build the auto-attribution sentence appended to \u007b{Info}}.
// Rules (with dedupe across author A, uploader U, nominator N):
// A === U === N β "My own work."
// A === N (and U == A) β "My own work."
// A === N (U different) β "I am the photographer; uploaded by <U>."
// A === U (A != N) β "Created and uploaded by <A>."
// U === N (A != U) β "Created by <A>." (no need to say I uploaded)
// All three different β "Created by <A>, uploaded by <U>."
// No A info, U == N β "" (nominator uploaded own; nothing to say)
// No A info, U != N β "Uploaded by <U>."
function buildAttributionLine( authorParsed, uploader, nominator ) {
if ( !authorParsed ) authorParsed = { raw: null, username: null };
var A = authorParsed.username;
var Araw = authorParsed.raw;
var U = uploader || null;
var N = nominator || null;
function userLink( u ) { return '[[User:' + u + '|' + u + ']]'; }
// Pick a display token for the author. If no username, use raw text
// (escape any wikilinks the field already contains is unnecessary,
// the value comes verbatim from the file's Information template).
var aLink = A ? userLink( A ) : ( Araw || null );
var uLink = U ? userLink( U ) : null;
if ( A && A === U && A === N ) return 'My own work.';
if ( A && A === N ) {
if ( U && U === A ) return 'My own work.';
if ( U && U !== A ) return 'I am the photographer; uploaded by ' + uLink + '.';
return 'I am the photographer.';
}
if ( A && A === U ) {
return 'Created and uploaded by ' + aLink + '.';
}
if ( aLink && U && U === N ) {
return 'Created by ' + aLink + '.';
}
if ( aLink && uLink ) {
return 'Created by ' + aLink + ', uploaded by ' + uLink + '.';
}
if ( aLink && !uLink ) {
return 'Created by ' + aLink + '.';
}
if ( !aLink && uLink && U !== N ) {
return 'Uploaded by ' + uLink + '.';
}
return '';
}
// βββ QIC own-work eligibility ββββββββββββββββββββββββββββββββββββββββββββ
// Quality Image Candidates require the image to be the own work of a
// Wikimedian, and this tool limits QIC nominations to the CURRENT USER's
// own work (the QIC link is disabled on any file that isn't yours). "Own
// work" is recognised from the file page when EITHER signal holds:
// 1. the |Author=/|artist=/|photographer= field resolves to the current
// user, a [[User:X]] / \u007b{u|X}} / \u007b{User:X}} / Special:Contributions
// link, or the owner's own \u007b{Creator:β¦}} template (see parseAuthorField).
// 2. the description carries an own-work marker (\u007b{own}}, \u007b{own work}} or
// \u007b{self}}) AND the file's ORIGINAL uploader is the current user, an
// own-work template asserts "uploader == author", so it only proves the
// current user's authorship when the uploader is the current user.
// Everything else (another Wikimedian's own work, a third party's
// \u007b{Creator:β¦}}, a plain real-name credit, a public-domain / agency source)
// is treated as NOT the user's own work.
var OWN_WORK_RE = /\{\{\s*(?:own(?:[ _]work)?|self)\s*[|}]/i;
function computeOwnWork( wt, uploader, user ) {
wt = wt || '';
var author = parseAuthorField( wt );
var uNorm = canonUser( user || '' );
var isOwn = false;
// (1) Author field resolves to the current user.
if ( uNorm && author.username && canonUser( author.username ) === uNorm ) {
isOwn = true;
}
// (2) Own-work marker + the file's uploader is the current user.
if ( !isOwn && uNorm && uploader && canonUser( uploader ) === uNorm && OWN_WORK_RE.test( wt ) ) {
isOwn = true;
}
return { isOwn: isOwn, author: author, uploader: uploader || null };
}
// One API round-trip: the file wikitext (author + own-work marker) plus the
// original uploader (imageinfo, oldest revision) β the computeOwnWork()
// verdict. On a read error it resolves { isOwn:true, unknown:true } so the
// link is NOT falsely disabled at load, the click-time re-check gates the
// actual nomination, and a persistent network failure there surfaces the
// generic precondition error instead of a wrong "not your own work" claim.
function checkFileOwnWork( filePageName, user, api ) {
return api.get( {
action: 'query', titles: filePageName,
prop: 'revisions|imageinfo',
rvprop: 'content', rvslots: 'main',
iiprop: 'user', iilimit: 'max',
format: 'json', formatversion: 2
} ).then( function ( res ) {
var p = ( res && res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
var wt = ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main && p.revisions[ 0 ].slots.main.content ) || '';
var revs = p.imageinfo || [];
var uploader = revs.length ? ( revs[ revs.length - 1 ].user || null ) : null;
return computeOwnWork( wt, uploader, user );
} ).catch( function () {
return { isOwn: true, unknown: true, author: { raw: null, username: null }, uploader: null };
} );
}
// Reason clause appended to the "limited to your own work" message. Name the
// credited author, else the uploader, when known.
function ownWorkReason( ow ) {
if ( ow && ow.author && ow.author.username ) return ' (this file credits ' + ow.author.username + ').';
if ( ow && ow.uploader ) return ' (uploaded by ' + ow.uploader + ').';
return '.';
}
// Single-file fetch + runChecks for the pre-submit readiness panel shown
// inside the Nominate dialog. Mirrors what runReadinessChecks does for the
// batch FPC-page case, condensed to one title. Includes MediaInfo caption /
// depicts checks because those are top-five blockers reviewers flag.
function fetchPreSubmitReadiness( filePageName, api ) {
return api.get( {
action: 'query', titles: filePageName,
prop: 'imageinfo|revisions|categories|globalusage|duplicatefiles',
iiprop: 'size|mime|url|commonmetadata|user|timestamp|sha1',
iilimit: 'max',
rvprop: 'content', rvslots: 'main',
cllimit: 'max', clshow: '!hidden',
gulimit: 50, guprop: 'url',
dflimit: 5,
format: 'json', formatversion: 2
} ).then( function ( res ) {
var p = ( res && res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
var allII = p.imageinfo || [];
var ii = allII[ 0 ] || {};
var firstUp = allII[ allII.length - 1 ];
if ( firstUp ) {
ii.__firstUploadTimestamp = firstUp.timestamp;
ii.__firstUploadUser = firstUp.user;
}
ii.__pageid = p.pageid;
ii.__duplicateFiles = ( p.duplicatefiles || [] ).map( function ( d ) { return d.name; } );
var wt = ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main && p.revisions[ 0 ].slots.main.content ) || '';
var cats = ( p.categories || [] ).map( function ( c ) { return c.title.replace( /^Category:/, '' ); } );
ii.__isFP = /\{\{\s*(?:Featured[ _]?(?:picture|image|media)|FP)\s*[}|]/i.test( wt ) ||
cats.some( function ( c ) { return /^Featured pictures?\b/i.test( c ); } );
ii.__isQI = /\{\{\s*(?:QualityImage|Quality[ _]image)\b/i.test( wt ) ||
cats.some( function ( c ) { return /^Quality images?\b/i.test( c ); } );
ii.__isVI = /\{\{\s*(?:Valued[ _]image|VI)\s*[|}]/i.test( wt ) ||
cats.some( function ( c ) { return /^Valued image\b/i.test( c ); } );
var gu = p.globalusage || [];
ii.__globalUsage = { articleCount: gu.length, truncated: gu.length >= 50 };
// Fetch MediaInfo statements BEFORE running checks so runChecks
// can consult them (P170 author / P571 date / P7482 source / etc.)
// and skip the corresponding wikitext-only checks when the data
// lives on the M-entity.
var miPromise = ii.__pageid ? api.get( {
action: 'wbgetentities',
ids: 'M' + ii.__pageid,
// M-entities expose statements (not claims). Request 'claims'
// because the API does not recognise 'statements' as a prop
// name, the server then populates the response under either
// statements or claims and we check both keys.
props: 'labels|claims',
format: 'json', formatversion: 2
} ).then( function ( r2 ) {
var ent = r2 && r2.entities && r2.entities[ 'M' + ii.__pageid ];
return {
labels: ( ent && ent.labels ) || {},
statements: ( ent && ( ent.statements || ent.claims ) ) || {}
};
} ).catch( function () { return { labels: {}, statements: {} }; } ) :
Promise.resolve( { labels: {}, statements: {} } );
return miPromise.then( function ( mi ) {
ii.__mediaInfoStatements = mi.statements;
ii.__mediaInfoLabels = mi.labels;
// Lightweight hard-rule checks only, the full quality engine
// (runChecks) stays in fpc-archiver.js and is not duplicated here.
var checks = runLightChecks( ii );
// MediaInfo caption: accept either a non-empty Wikibase label
// or a non-empty Information template description (most
// uploaders write descriptions in |description=\u007b{en|β¦}}).
var hasLabel = Object.keys( mi.labels ).some( function ( k ) {
return mi.labels[ k ] && mi.labels[ k ].value && mi.labels[ k ].value.trim();
} );
var hasInfoDescription = /\|\s*[Dd]escription\s*=\s*\{?\{?\s*[a-z]{2,3}\s*\|\s*[^\s}|][^\n}]*/i.test( wt ) ||
/\|\s*[Dd]escription\s*=\s*[^\n|{][^\n|]*/.test( wt );
if ( !hasLabel && !hasInfoDescription ) {
checks.push( { name: 'MediaInfo caption empty', pass: false,
note: 'No caption is set in MediaInfo for any language and the Information template has no description either. Add a short caption on the file page before submitting, reviewers expect at least a one-sentence description.' } );
}
// Depicts: accept P180 OR a template-based equivalent
// (\u007b{Depicts}}, \u007b{Wikidata}}, \u007b{Object location}}, etc.).
var hasP180 = mi.statements.P180 && mi.statements.P180.length;
var hasTplDepicts = /\{\{\s*(?:Depicts|Wikidata|Object[ _]location|based on|main subject)\b/i.test( wt );
if ( !hasP180 && !hasTplDepicts ) {
checks.push( { name: 'No depicts (P180) statement', pass: false,
note: 'MediaInfo has no "depicts" (P180) statement and the file description has no \u007b{Depicts}} / \u007b{Wikidata}} template either. Add at least one depicts entry on the file page before submitting.' } );
}
return { checks: checks, imageinfo: ii, wt: wt, cats: cats };
} );
} );
}
// Render the verdict pill + 3-5 most-relevant signals into the dialog's
// readiness body. Severity is a copy of renderReadinessBadge's heuristic,
// critical issues bubble to the top, the rest collapse into a "+ N more".
function renderPreSubmitReadiness( data, container ) {
var checks = data.checks || [];
var failed = checks.filter( function ( c ) { return !c.pass; } );
container.innerHTML = '';
var pill = document.createElement( 'div' );
pill.className = 'fpc-nominate-readiness-pill';
if ( failed.length === 0 ) {
pill.classList.add( 'fpc-ready-ok' );
pill.textContent = 'β FP-ready (no metadata-level issues)';
container.appendChild( pill );
return;
}
var criticalRe = /Heavy crop|Severely (?:under|over)exposed|Hard (?:highlight|shadow) clipping|Possible AI-generated|Single colour channel clipped|Tilted (?:horizon|verticals)|Chromatic aberration|Visible noise|Native camera resolution|JPEG blocking|Lens distortion|Heavy vignetting|Banding|Date predates camera|Stitching seam|Over-sharpening|Over-cooked HDR|Information \/ Artwork|missing|Below 60%|Exported below|blown|crushed|MediaInfo|depicts/i;
var score = failed.reduce( function ( s, c ) { return s + ( criticalRe.test( c.name ) ? 2 : 1 ); }, 0 );
if ( failed.length <= 2 && score <= 3 ) {
pill.classList.add( 'fpc-ready-borderline' );
pill.textContent = 'β Borderline (' + failed.length + ' issue' + ( failed.length > 1 ? 's' : '' ) + ')';
} else {
pill.classList.add( 'fpc-ready-risky' );
pill.textContent = 'β Risky (' + failed.length + ' issues, severity ' + Math.min( 5, Math.ceil( score / 2 ) ) + '/5)';
}
container.appendChild( pill );
// Critical first, then the rest. Cap at 5 lines, overflow into "more".
failed.sort( function ( a, b ) {
return ( criticalRe.test( b.name ) ? 1 : 0 ) - ( criticalRe.test( a.name ) ? 1 : 0 );
} );
var lines = document.createElement( 'div' );
lines.className = 'fpc-nominate-readiness-lines';
// Right column has room, show every issue with the full explanation
// visible inline (no truncation, no hover-to-reveal). Severity badge in
// front of the name makes the priority scannable.
var maxShown = 8;
failed.slice( 0, maxShown ).forEach( function ( c ) {
var item = document.createElement( 'div' );
item.className = 'fpc-nominate-readiness-line';
var crit = criticalRe.test( c.name );
if ( crit ) item.classList.add( 'fpc-nominate-readiness-line-critical' );
var badge = crit ? '<span class="fpc-nominate-readiness-badge fpc-readiness-badge-crit">!</span>' :
'<span class="fpc-nominate-readiness-badge fpc-readiness-badge-warn">β’</span>';
item.innerHTML = badge + '<div class="fpc-nominate-readiness-line-body">' +
'<div class="fpc-nominate-readiness-line-name">' + escapeAttr( c.name ) + '</div>' +
'<div class="fpc-nominate-readiness-line-note">' + escapeAttr( c.note || '' ) + '</div>' +
'</div>';
lines.appendChild( item );
} );
container.appendChild( lines );
if ( failed.length > maxShown ) {
var more = document.createElement( 'div' );
more.className = 'fpc-nominate-readiness-more';
more.textContent = 'β¦ and ' + ( failed.length - maxShown ) + ' more (full list runs on the FPC page after submit).';
container.appendChild( more );
}
}
// Live prefix-search against Commons:Featured pictures/* subpages so the
// Gallery field autocompletes to a real, existing target instead of a
// redlinked guess. Strips the "Commons:Featured pictures/" prefix when
// inserting the result (the script always adds it back at submit time).
function setupGalleryAutocomplete( input, dropdown, api ) {
var debounceTimer = null;
var lastQuery = '';
var activeIdx = -1;
var currentResults = [];
function GALLERY_PREFIX() { return 'Commons:Featured pictures/'; }
function close() {
dropdown.hidden = true;
dropdown.innerHTML = '';
activeIdx = -1;
currentResults = [];
}
function render( results, query ) {
currentResults = results;
dropdown.innerHTML = '';
if ( !results.length ) {
dropdown.hidden = true;
return;
}
results.forEach( function ( r, i ) {
var item = document.createElement( 'div' );
item.className = 'fpc-nominate-gallery-suggest-item';
if ( i === activeIdx ) item.classList.add( 'active' );
// Highlight the user's typed substring inside the suggestion.
var label = r;
if ( query ) {
var qLower = query.toLowerCase();
var rLower = label.toLowerCase();
var idx = rLower.indexOf( qLower );
if ( idx >= 0 ) {
label = escapeAttr( r.slice( 0, idx ) ) +
'<mark>' + escapeAttr( r.slice( idx, idx + query.length ) ) + '</mark>' +
escapeAttr( r.slice( idx + query.length ) );
} else {
label = escapeAttr( r );
}
} else {
label = escapeAttr( r );
}
item.innerHTML = label;
item.addEventListener( 'mousedown', function ( e ) {
// mousedown not click, fires before blur so the dropdown
// doesn't close before our handler runs.
e.preventDefault();
input.value = r;
close();
input.focus();
input.dispatchEvent( new Event( 'change' ) );
} );
dropdown.appendChild( item );
} );
dropdown.hidden = false;
}
function search( raw ) {
// Strip section anchor for the search, galleries are pages, anchors
// are headings inside them. The user types "Plants/Asterales#Subfamily"
// but we search "Plants/Asterales".
var q = raw.split( '#' )[ 0 ].trim();
if ( !q ) { close(); return; }
if ( q === lastQuery ) return;
lastQuery = q;
api.get( {
action: 'query',
list: 'prefixsearch',
pssearch: GALLERY_PREFIX() + q,
psnamespace: 4,
pslimit: 10,
format: 'json', formatversion: 2
} ).done( function ( res ) {
if ( q !== lastQuery ) return; // a newer query has been issued
var matches = ( res && res.query && res.query.prefixsearch ) || [];
// Reject the same non-gallery patterns the tree filters out
// (file-shaped subpages, namespace-prefixed pages, media leaf
// names) so the autocomplete never offers a path that would
// produce a broken \u007b{Gallery:}} link.
var NON_GALLERY = /(?:^|\/)(?:File|Image|Talk|Special|Category|User|Media|MediaWiki):/i;
var MEDIA_LEAF = /\.(?:jpg|jpeg|png|gif|svg|tif|tiff|webp|ogv|webm|pdf|djvu)$/i;
var paths = matches
.map( function ( m ) { return ( m.title || '' ).replace( /^Commons:Featured pictures\//, '' ); } )
.filter( function ( p ) {
if ( !p ) return false;
if ( NON_GALLERY.test( p ) ) return false;
if ( MEDIA_LEAF.test( p.split( '/' ).pop() || '' ) ) return false;
return true;
} );
render( paths, q );
} ).fail( function () { /* silent, autocomplete is best-effort */ } );
}
input.addEventListener( 'input', function () {
clearTimeout( debounceTimer );
debounceTimer = setTimeout( function () { search( input.value ); }, 200 );
} );
input.addEventListener( 'focus', function () {
// Re-open on focus if the user already has text typed.
if ( input.value.trim() ) search( input.value );
} );
input.addEventListener( 'blur', function () {
// Small delay so a click on a suggestion still registers.
setTimeout( close, 150 );
} );
input.addEventListener( 'keydown', function ( e ) {
if ( dropdown.hidden ) return;
if ( e.key === 'ArrowDown' ) {
e.preventDefault();
activeIdx = Math.min( currentResults.length - 1, activeIdx + 1 );
render( currentResults, lastQuery );
} else if ( e.key === 'ArrowUp' ) {
e.preventDefault();
activeIdx = Math.max( -1, activeIdx - 1 );
render( currentResults, lastQuery );
} else if ( e.key === 'Enter' && activeIdx >= 0 ) {
e.preventDefault();
input.value = currentResults[ activeIdx ];
close();
} else if ( e.key === 'Escape' ) {
e.preventDefault();
close();
}
} );
}
// Parse Template:Commons FP galleries into a map of gallery path β icon
// filename so the tree browser can show the same visual cue Commons uses
// in its index navbox. Cached at the module level so repeated dialog opens
// don't re-fetch.
function loadFpgIconMap( api ) {
if ( fpgIconMapPromise ) return fpgIconMapPromise;
fpgIconMapPromise = api.get( {
action: 'query',
titles: 'Template:Commons FP galleries',
prop: 'revisions',
rvprop: 'content', rvslots: 'main',
format: 'json', formatversion: 2
} ).then( function ( res ) {
var p = ( res && res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
var wt = ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main && p.revisions[ 0 ].slots.main.content ) || '';
var map = {};
// Match `[\u005bFile:<icon>|...]] [[Commons:Featured pictures/<path>|...]]`
// (the row format the template uses for every link). First match
// wins so the most prominent listing of a path takes precedence.
var re = /\[\[\s*File\s*:\s*([^|\]\n]+?)\s*\|[^\]\n]*\]\]\s*\[\[\s*Commons:Featured pictures\/([^|\]\n]+?)\s*(?:\||\]\])/g;
var m;
while ( ( m = re.exec( wt ) ) ) {
var icon = m[ 1 ].trim();
var path = m[ 2 ].trim();
if ( icon && path && !map[ path ] ) map[ path ] = icon;
}
return map;
} ).catch( function () { return {}; } );
return fpgIconMapPromise;
}
// Build the Special:FilePath URL for a Commons file at a target width.
// Used by the tree browser to render the navbox icon next to each row.
function commonsThumbUrl( filename, width ) {
return 'https://commons.wikimedia.org/wiki/Special:FilePath/' +
encodeURIComponent( filename ) + '?width=' + ( width || 20 );
}
// Fetch a small batch of representative images from the given FP gallery
// path. Two modes:
// β’ No section anchor β grab images from the whole page in one round
// trip with generator=images.
// β’ With section anchor (e.g. "Animals/Amphibians#Gallery of Amphibia") β
// look up the section index, then ask action=parse for just that
// section's images so we only sample the relevant subset (not every
// image on the parent page).
// Filter out SVG icons + tiny files so navbox icons / nav arrows don't
// pollute the grid. Results cached per (path, section?) combo.
var fpgSamplesCache = {};
function fetchGallerySamples( galleryPath, sectionAnchor, api ) {
// Back-compat: callers used to pass (path, api) when there was no
// section concept. Detect and shift.
if ( sectionAnchor && typeof sectionAnchor !== 'string' && !api ) {
api = sectionAnchor;
sectionAnchor = null;
}
var cacheKey = galleryPath + ( sectionAnchor ? '#' + sectionAnchor : '' );
if ( fpgSamplesCache[ cacheKey ] ) return Promise.resolve( fpgSamplesCache[ cacheKey ] );
var pageTitle = 'Commons:Featured pictures/' + galleryPath;
function postFilter( samples ) {
return samples.filter( function ( s ) {
if ( !s.thumb ) return false;
if ( /svg/.test( s.mime || '' ) ) return false;
if ( ( s.width || 0 ) < 200 ) return false;
return true;
} ).slice( 0, 12 );
}
function whole() {
return api.get( {
action: 'query',
titles: pageTitle,
generator: 'images',
gimlimit: 100,
prop: 'imageinfo',
iiprop: 'url|size|mime',
iiurlwidth: 150,
format: 'json', formatversion: 2
} ).then( function ( res ) {
var pages = ( res && res.query && res.query.pages ) || [];
return postFilter( pages.map( function ( p ) {
var ii = ( p.imageinfo && p.imageinfo[ 0 ] ) || {};
return {
title: p.title,
thumb: ii.thumburl,
url: ii.descriptionurl || ( '/wiki/' + encodeURIComponent( p.title ) ),
mime: ii.mime,
width: ii.width,
height: ii.height
};
} ) );
} );
}
function sectionImages( sectionIdx ) {
return api.get( {
action: 'parse',
page: pageTitle,
section: sectionIdx,
prop: 'images',
format: 'json', formatversion: 2
} ).then( function ( r ) {
var names = ( r && r.parse && r.parse.images ) || [];
if ( !names.length ) return [];
// imageinfo for the section\'s files (cap at 50, MW limit).
var titles = names.slice( 0, 50 ).map( function ( n ) { return 'File:' + n; } );
return api.get( {
action: 'query',
titles: titles.join( '|' ),
prop: 'imageinfo',
iiprop: 'url|size|mime',
iiurlwidth: 150,
format: 'json', formatversion: 2
} ).then( function ( res ) {
var pages = ( res && res.query && res.query.pages ) || [];
return postFilter( pages.map( function ( p ) {
var ii = ( p.imageinfo && p.imageinfo[ 0 ] ) || {};
return {
title: p.title,
thumb: ii.thumburl,
url: ii.descriptionurl || ( '/wiki/' + encodeURIComponent( p.title ) ),
mime: ii.mime,
width: ii.width,
height: ii.height
};
} ) );
} );
} );
}
var resultPromise;
if ( sectionAnchor ) {
// Resolve section name β index via prop=sections, then fetch.
resultPromise = api.get( {
action: 'parse',
page: pageTitle,
prop: 'sections',
format: 'json', formatversion: 2
} ).then( function ( r ) {
var secs = ( r && r.parse && r.parse.sections ) || [];
var target = String( sectionAnchor ).replace( /<[^>]+>/g, '' ).trim();
var match = null;
for ( var i = 0; i < secs.length; i++ ) {
var clean = String( secs[ i ].line || '' ).replace( /<[^>]+>/g, '' ).trim();
if ( clean === target || secs[ i ].anchor === target.replace( /\s+/g, '_' ) ) {
match = secs[ i ];
break;
}
}
if ( !match ) return whole(); // unknown section β safe fallback
return sectionImages( match.index );
} ).catch( function () { return whole(); } );
} else {
resultPromise = whole();
}
return resultPromise.then( function ( samples ) {
fpgSamplesCache[ cacheKey ] = samples;
return samples;
} );
}
function renderGallerySamples( samples, container, path, section ) {
container.innerHTML = '';
var hrefPath = '/wiki/Commons:Featured%20pictures/' + encodeURI( path ) +
( section ? '#' + encodeURIComponent( section ).replace( /%20/g, '_' ) : '' );
var labelPath = path + ( section ? ' Β§ ' + section : '' );
if ( !samples.length ) {
container.innerHTML = '<div class="fpc-nominate-samples-empty">No images found in <a href="' +
escapeAttr( hrefPath ) + '" target="_blank">this ' + ( section ? 'section' : 'gallery page' ) +
'</a>. The path may be empty or new.</div>';
return;
}
var grid = document.createElement( 'div' );
grid.className = 'fpc-nominate-samples-grid';
samples.forEach( function ( s ) {
var a = document.createElement( 'a' );
a.href = s.url;
a.target = '_blank';
a.rel = 'noopener';
a.className = 'fpc-nominate-samples-thumb';
a.title = s.title.replace( /^File:/, '' );
var img = document.createElement( 'img' );
img.src = s.thumb;
img.loading = 'lazy';
img.alt = '';
a.appendChild( img );
grid.appendChild( a );
} );
container.appendChild( grid );
var meta = document.createElement( 'div' );
meta.className = 'fpc-nominate-samples-meta';
meta.innerHTML = 'Showing ' + samples.length + ' image' + ( samples.length === 1 ? '' : 's' ) +
' in <a href="' + escapeAttr( hrefPath ) + '" target="_blank">' + escapeAttr( labelPath ) + '</a>.';
container.appendChild( meta );
}
// Fetch short Wikidata descriptions for a batch of taxon / topic names so
// the tree browser can show "order of mammals" under "Carnivora", etc.
// Resolves via the English Wikipedia sitelink, then reads the Wikidata
// description for the en language. CORS-enabled, so we hit wikidata.org
// directly from the script.
var fpgDescCache = {};
function fetchTaxonDescriptions( names ) {
var titles = names.map( function ( n ) { return n.split( '/' ).pop(); } );
var uncached = titles.filter( function ( t ) { return !( t in fpgDescCache ); } );
if ( !uncached.length ) {
var hit = {};
titles.forEach( function ( t ) { hit[ t ] = fpgDescCache[ t ]; } );
return Promise.resolve( hit );
}
var url = 'https://www.wikidata.org/w/api.php?' + new URLSearchParams( {
action: 'wbgetentities',
sites: 'enwiki',
titles: uncached.join( '|' ),
props: 'descriptions',
languages: 'en',
format: 'json',
origin: '*'
} ).toString();
return fetch( url ).then( function ( r ) { return r.json(); } ).then( function ( d ) {
var entities = d && d.entities || {};
Object.keys( entities ).forEach( function ( q ) {
var ent = entities[ q ];
var label = ent.sitelinks && ent.sitelinks.enwiki && ent.sitelinks.enwiki.title;
var desc = ent.descriptions && ent.descriptions.en && ent.descriptions.en.value;
if ( label && desc ) fpgDescCache[ label ] = desc;
} );
// Cache misses as empty string so we don't refetch.
uncached.forEach( function ( t ) {
if ( !( t in fpgDescCache ) ) fpgDescCache[ t ] = '';
} );
var out = {};
titles.forEach( function ( t ) { out[ t ] = fpgDescCache[ t ]; } );
return out;
} ).catch( function () {
// Cache misses so we don't keep retrying a broken endpoint.
uncached.forEach( function ( t ) { fpgDescCache[ t ] = ''; } );
var out = {};
titles.forEach( function ( t ) { out[ t ] = ''; } );
return out;
} );
}
// Smart pre-selection of the FP gallery that best matches the file. Walks
// the file\'s topical categories up to two hops looking for any segment
// that matches a known FP gallery leaf name (e.g., file in [\u005bCategory:
// Felis catus]] β parent has "Felidae" β grandparent has "Carnivora",
// which matches the gallery "Animals/Mammals/Carnivora"). Falls back to
// null when nothing in two hops matches, caller can then drop to the
// rule-based suggestGallery() heuristic.
function smartSuggestGallery( catNames, iconMap, api ) {
if ( !iconMap || !catNames || !catNames.length ) return Promise.resolve( null );
// Build leaf-name β full path index. Each iconMap key is a real FP
// gallery path (e.g. "Animals/Mammals/Carnivora"); we key by the last
// segment. When a leaf name exists under several tops (e.g. "People" is
// both the top-level "People" gallery AND "Non-photographic
// media/People") prefer the SHALLOWEST, most general path, the safe
// guess. Preferring the deepest path used to send a portrait photo to
// "Non-photographic media/People". Unique leaves (Carnivora,
// Passeriformes, Cityscapesβ¦) resolve the same either way.
var leafIndex = {};
var pathSet = {};
var pathDepth = function ( p ) { return p.split( '/' ).length; };
Object.keys( iconMap ).forEach( function ( path ) {
pathSet[ path ] = true;
var leaf = path.split( '/' ).pop();
var prev = leafIndex[ leaf ];
if ( !prev || pathDepth( path ) < pathDepth( prev ) ||
( pathDepth( path ) === pathDepth( prev ) && path.length < prev.length ) ) {
leafIndex[ leaf ] = path;
}
} );
// Pre-build a normalised-leaf index so contains-match doesn\'t cost a
// full scan per category.
var leafLowerIndex = {};
Object.keys( leafIndex ).forEach( function ( leaf ) {
leafLowerIndex[ leaf.toLowerCase() ] = leafIndex[ leaf ];
} );
// Candidate name variants generated from a category title. Includes
// common Commons category patterns:
// "Paintings by Leonardo da Vinci" β "Paintings"
// "Mammals of Africa" β "Mammals"
// "Birds in flight" β "Birds"
// "Plants from Spain" β "Plants"
// "Featured photographs of Carnivora" β "Carnivora"
function variantsOf( name ) {
var out = [];
function push( s ) { s = ( s || '' ).trim(); if ( s ) out.push( s ); }
push( name );
push( name.toLowerCase() );
push( name.replace( /s$/, '' ) );
push( name.replace( /\s+(?:photographs|pictures|photos|images)$/i, '' ) );
// Strip common trailing phrases that wrap a leaf with provenance.
var stripped = name.replace( /\s+(?:by|of|from|in|on|at)\s+.+$/i, '' );
if ( stripped !== name ) push( stripped );
// Strip common leading qualifiers.
var unprefixed = name.replace( /^(?:Featured |Quality |Valued |Historical |Modern )/i, '' );
if ( unprefixed !== name ) push( unprefixed );
// Combine both prefixes / suffixes.
var both = unprefixed.replace( /\s+(?:by|of|from|in|on|at)\s+.+$/i, '' );
if ( both !== unprefixed && both !== name ) push( both );
return out;
}
function tryMatch( cats ) {
// Pass 1: exact leaf match using any variant.
for ( var i = 0; i < cats.length; i++ ) {
var c = cats[ i ];
var vs = variantsOf( c );
for ( var j = 0; j < vs.length; j++ ) {
var hit = leafIndex[ vs[ j ] ] || leafLowerIndex[ vs[ j ].toLowerCase() ];
if ( hit ) return hit;
}
}
// Pass 2: substring match, pick the longest leaf name that
// appears as a word inside the category. Bounded to leaves β₯ 5
// chars so short noun matches ("Art", "Sea") don\'t fire on
// unrelated cats. Picks the longest match if many fit.
var bestHit = null;
var bestLen = 0;
for ( var k = 0; k < cats.length; k++ ) {
var lcCat = ( ' ' + cats[ k ].toLowerCase() + ' ' );
Object.keys( leafLowerIndex ).forEach( function ( leaf ) {
if ( leaf.length < 5 ) return;
var re = new RegExp( '\\b' + leaf.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ) + '\\b' );
if ( re.test( lcCat ) && leaf.length > bestLen ) {
bestHit = leafLowerIndex[ leaf ];
bestLen = leaf.length;
}
} );
}
return bestHit;
}
// Level 0: file\'s own categories.
var direct = tryMatch( catNames );
if ( direct ) return Promise.resolve( direct );
// Level 1: parent categories.
return fetchParentCategories( catNames, api ).then( function ( parents ) {
var lvl1 = tryMatch( parents );
if ( lvl1 ) return lvl1;
// Level 2: grandparent categories.
return fetchParentCategories( parents, api ).then( function ( grandparents ) {
var lvl2 = tryMatch( grandparents );
if ( lvl2 ) return lvl2;
// Level 3: great-grandparents (last resort). Most real-world
// category trees on Commons are 4β5 levels above the leaf for
// a species, so this final hop catches them.
return fetchParentCategories( grandparents, api ).then( function ( ggp ) {
return tryMatch( ggp ) || null;
} );
} );
} );
}
// Returns the (deduplicated) set of parent categories of the given list of
// category names. One API call regardless of input size, capped at 50
// titles per request (the MW limit) so we slice if a Wikidata-rich file
// has more than that many topical cats.
function fetchParentCategories( catNames, api ) {
if ( !catNames || !catNames.length ) return Promise.resolve( [] );
var slice = catNames.slice( 0, 50 ).map( function ( c ) { return 'Category:' + c; } );
return api.get( {
action: 'query',
titles: slice.join( '|' ),
prop: 'categories',
cllimit: 'max',
clshow: '!hidden',
format: 'json', formatversion: 2
} ).then( function ( res ) {
var pages = ( res && res.query && res.query.pages ) || [];
var seen = {};
var out = [];
pages.forEach( function ( p ) {
( p.categories || [] ).forEach( function ( c ) {
var name = ( c.title || '' ).replace( /^Category:/, '' );
if ( name && !seen[ name ] ) {
seen[ name ] = true;
out.push( name );
}
} );
} );
return out;
} ).catch( function () { return []; } );
}
// Tree browser for the Featured pictures gallery hierarchy. Drills into
// Commons:Featured pictures/<X>/<Y>/β¦ one level at a time, then offers
// section anchors of the final page so the picked path lands directly on
// the relevant subsection. allpages results are cached per-prefix so
// repeat navigations don't re-hit the API.
function setupGalleryTreeBrowser( tree, suggest, input, api, onNavigate ) {
var cache = {}; // prefix -> { children: [paths], sections: [titles] }
var current = ''; // empty = root (under Commons:Featured pictures/)
var PREFIX = 'Featured pictures/';
var FULL_PREFIX = 'Commons:' + PREFIX;
var iconMap = null;
var iconMapResolving = false;
// Initial position: parent of the pre-filled suggestion so the user
// sees siblings, most useful entry point. Empty input β root.
var startPath = ( input.value || '' ).split( '#' )[ 0 ].replace( /\/$/, '' );
if ( startPath && startPath.indexOf( '/' ) >= 0 ) {
startPath = startPath.slice( 0, startPath.lastIndexOf( '/' ) );
} else {
startPath = '';
}
navigate( startPath );
// Initial samples preview should reflect the pre-filled value (input)
// if any, otherwise the start path the tree opens at.
if ( onNavigate ) onNavigate( ( input.value || '' ).split( '#' )[ 0 ] || startPath );
// Icon-map fetch in parallel; re-renders the current level when ready.
iconMapResolving = true;
loadFpgIconMap( api ).then( function ( m ) {
iconMap = m;
if ( cache[ current ] ) renderLevel( current, cache[ current ] );
} );
function navigate( prefix ) {
current = prefix;
tree.innerHTML = '<div class="fpc-nominate-gallery-tree-loading">Loadingβ¦</div>';
// Tell the host (right column) about the new level so it can
// refresh its samples panel even though the input hasn\'t changed.
if ( onNavigate ) onNavigate( prefix );
fetchLevel( prefix ).then( function ( data ) {
if ( current !== prefix ) return; // user navigated away
renderLevel( prefix, data );
} ).catch( function () {
tree.innerHTML = '<div class="fpc-nominate-gallery-tree-empty">Could not load, try again.</div>';
} );
}
function fetchLevel( prefix ) {
if ( cache[ prefix ] ) return Promise.resolve( cache[ prefix ] );
// Two concurrent calls: (1) child pages under this prefix via
// allpages, (2) sections of the page AT this prefix (if it exists)
// via parse, only meaningful when prefix is non-empty.
var fullPrefix = FULL_PREFIX + ( prefix ? prefix + '/' : '' );
var childrenP = api.get( {
action: 'query',
list: 'allpages',
apprefix: PREFIX + ( prefix ? prefix + '/' : '' ),
apnamespace: 4,
aplimit: 500,
format: 'json', formatversion: 2
} ).then( function ( res ) {
var pages = ( res && res.query && res.query.allpages ) || [];
// Reduce to immediate-child segments only (Plants/Asterales,
// not Plants/Asterales/Subfamily/etc.). Strip the FULL_PREFIX
// and our current sub-prefix, then keep what comes before the
// next "/".
var seen = {};
var children = [];
// 2-3 letter all-lowercase segments are ISO-639 language codes
// (de, es, fr, ja, ru, zh-β¦) for translated gallery indexes
// maintained by each language community, NOT nomination
// targets. Filter them out at the root level so the user
// sees content galleries (Plants, Animals, β¦) only. Deeper
// levels keep all children, they're already content paths.
var isRoot = !prefix;
var LANG_CODE_RE = /^[a-z]{2,3}(?:-[a-z0-9]+)?$/;
// Skip subpages that aren\'t real gallery destinations:
// β’ File:<name>.<ext> β someone created a subpage shaped
// like a file ref under Commons:Featured pictures/;
// selecting one as a gallery would write a broken link.
// β’ Image:<name> β legacy alias for File:.
// β’ <name>.<ext> β filename-shaped leaf with a media
// extension (jpg, png, β¦) but no namespace.
// β’ Talk pages / candidate-list maintenance subpages.
var NON_GALLERY_RE = /^(?:File|Image|Talk|Special|Category|User|Media|MediaWiki):/i;
var MEDIA_LIKE_RE = /\.(?:jpg|jpeg|png|gif|svg|tif|tiff|webp|ogv|webm|pdf|djvu)$/i;
pages.forEach( function ( p ) {
var rest = ( p.title || '' ).slice( fullPrefix.length );
if ( !rest ) return;
var nextSeg = rest.split( '/' )[ 0 ];
if ( !nextSeg || seen[ nextSeg ] ) return;
if ( isRoot && LANG_CODE_RE.test( nextSeg ) ) return;
if ( NON_GALLERY_RE.test( nextSeg ) ) return;
if ( MEDIA_LIKE_RE.test( nextSeg ) ) return;
seen[ nextSeg ] = true;
children.push( ( prefix ? prefix + '/' : '' ) + nextSeg );
} );
children.sort();
return children;
} );
var sectionsP = prefix ? api.get( {
action: 'parse',
page: FULL_PREFIX + prefix,
prop: 'sections',
format: 'json', formatversion: 2
} ).then( function ( res ) {
var secs = ( res && res.parse && res.parse.sections ) || [];
// sec.line may contain HTML (bold, italics, links). Strip tags
// for display. Filter out "Gallery of <page>", that's the
// page-level boilerplate heading, not a useful subsection
// anchor (selecting it is equivalent to picking the page
// without an anchor at all).
var pageName = prefix.split( '/' ).pop();
var redundantRe = new RegExp( '^\\s*Gallery of\\s+' + pageName.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ) + '\\s*$', 'i' );
// Include headings up to level 3 (=== Subfamily : β¦ ===) so
// taxonomically-deeper FP galleries (Plants/Asterales has
// Order > Family > Subfamily) are reachable. Level 4+ tends
// to be noise (image counts, etc.) and is left out.
return secs.filter( function ( s ) {
var lvl = parseInt( s.level, 10 );
return ( lvl >= 1 && lvl <= 3 );
} )
.map( function ( s ) {
// Many FP gallery section headings render with a U+00A0
// non-breaking space (e.g. \"Subfamily : Foo\") which
// looks identical to a regular space on screen but
// would never match if the user typed a normal space
// into the gallery input. Normalize to plain spaces so
// both display and the committed anchor are consistent.
var raw = String( s.line || s.anchor || '' )
.replace( /<[^>]+>/g, '' )
.replace( /Β /g, ' ' )
.trim();
return raw;
} )
.filter( function ( t ) { return t && !redundantRe.test( t ); } );
} ).catch( function () { return []; } ) : Promise.resolve( [] );
return Promise.all( [ childrenP, sectionsP ] ).then( function ( r ) {
cache[ prefix ] = { children: r[ 0 ], sections: r[ 1 ] };
return cache[ prefix ];
} );
}
function renderLevel( prefix, data ) {
tree.innerHTML = '';
// Breadcrumb. Each segment is clickable to drill back.
var crumbs = document.createElement( 'div' );
crumbs.className = 'fpc-nominate-gallery-tree-crumbs';
var rootCrumb = document.createElement( 'a' );
rootCrumb.href = '#';
rootCrumb.textContent = 'Featured pictures';
rootCrumb.addEventListener( 'click', function ( e ) { e.preventDefault(); navigate( '' ); } );
crumbs.appendChild( rootCrumb );
if ( prefix ) {
var parts = prefix.split( '/' );
var accum = '';
parts.forEach( function ( p, i ) {
var sep = document.createElement( 'span' );
sep.textContent = ' / ';
sep.className = 'fpc-nominate-gallery-tree-sep';
crumbs.appendChild( sep );
accum = accum ? accum + '/' + p : p;
var a = document.createElement( 'a' );
a.href = '#';
a.textContent = p;
var localAccum = accum;
a.addEventListener( 'click', function ( e ) { e.preventDefault(); navigate( localAccum ); } );
crumbs.appendChild( a );
} );
}
tree.appendChild( crumbs );
// "Pick this page" shortcut when we're inside a leaf page.
if ( prefix ) {
var pickHere = document.createElement( 'button' );
pickHere.type = 'button';
pickHere.className = 'fpc-nominate-gallery-tree-pick-here';
pickHere.textContent = 'β Use ' + prefix + ' (no section)';
pickHere.addEventListener( 'click', function () { commit( prefix, '' ); } );
tree.appendChild( pickHere );
}
// Body: two columns (subgalleries on left, sections on right)
// when both are present; one column otherwise.
var body = document.createElement( 'div' );
body.className = 'fpc-nominate-gallery-tree-body';
tree.appendChild( body );
if ( data.children.length ) {
var col1 = document.createElement( 'div' );
col1.className = 'fpc-nominate-gallery-tree-col';
var h1 = document.createElement( 'div' );
h1.className = 'fpc-nominate-gallery-tree-colhead';
h1.textContent = data.children.length + ' subgallery group' + ( data.children.length > 1 ? 'ies' : '' );
col1.appendChild( h1 );
data.children.forEach( function ( path ) {
var item = document.createElement( 'div' );
item.className = 'fpc-nominate-gallery-tree-item';
var name = path.split( '/' ).pop();
var label = escapeAttr( name );
// Prefer the navbox icon from Template:Commons FP galleries,
// it gives a visual cue (silhouette, emoji) that matches
// what Commons uses elsewhere. Fallback to π if no entry.
var icon = iconMap && iconMap[ path ];
var iconHtml = icon
? '<img class="fpc-nominate-gallery-tree-icon" src="' +
escapeAttr( commonsThumbUrl( icon, 20 ) ) + '" alt="" loading="lazy">'
: '<span class="fpc-nominate-gallery-tree-icon-fallback">π</span>';
item.innerHTML = iconHtml +
'<div class="fpc-nominate-gallery-tree-text">' +
'<div class="fpc-nominate-gallery-tree-name">' + label + '</div>' +
'<div class="fpc-nominate-gallery-tree-desc" data-name="' + escapeAttr( name ) + '"></div>' +
'</div>';
item.title = path;
item.addEventListener( 'click', function () { navigate( path ); } );
col1.appendChild( item );
} );
// Lazy-fetch one-line descriptions for every visible row in a
// single Wikidata batch (re-used cache across navigations).
var allNames = data.children.map( function ( p ) { return p.split( '/' ).pop(); } );
if ( allNames.length ) {
fetchTaxonDescriptions( allNames ).then( function ( descMap ) {
col1.querySelectorAll( '.fpc-nominate-gallery-tree-desc' ).forEach( function ( el ) {
var n = el.getAttribute( 'data-name' );
var d = descMap[ n ];
if ( d ) el.textContent = d;
} );
} );
}
body.appendChild( col1 );
}
if ( data.sections.length ) {
var col2 = document.createElement( 'div' );
col2.className = 'fpc-nominate-gallery-tree-col';
var h2 = document.createElement( 'div' );
h2.className = 'fpc-nominate-gallery-tree-colhead';
h2.textContent = data.sections.length + ' section' + ( data.sections.length > 1 ? 's' : '' );
col2.appendChild( h2 );
data.sections.forEach( function ( sec ) {
var item = document.createElement( 'div' );
item.className = 'fpc-nominate-gallery-tree-item fpc-nominate-gallery-tree-section';
item.innerHTML = 'π ' + escapeAttr( sec );
item.title = prefix + '#' + sec;
item.addEventListener( 'click', function () { commit( prefix, sec ); } );
col2.appendChild( item );
} );
body.appendChild( col2 );
}
if ( !data.children.length && !data.sections.length ) {
var empty = document.createElement( 'div' );
empty.className = 'fpc-nominate-gallery-tree-empty';
empty.textContent = 'This level has no subgalleries or sections. Use the breadcrumb to back out.';
tree.appendChild( empty );
}
}
function commit( prefix, section ) {
input.value = section ? ( prefix + '#' + section ) : prefix;
input.dispatchEvent( new Event( 'change' ) );
}
}
// Post a courtesy notification on the uploader's User talk page (in English).
function postTalkNotification( uploader, filePageName, fileTitle, subpageTitle, api ) {
var section = 'Featured picture nomination of ' + fileTitle;
var text =
'Hi \u007b{ping|' + uploader + '}}, just letting you know that I have nominated your image [[:' + filePageName + '|' + fileTitle + ']] ' +
'for Featured Picture status. You can follow the discussion and (if you wish) participate at ' +
'[[' + subpageTitle + ']]. The voting period is nine days; criticism is normal and not personal. ' +
'Cheers, --\u007e\u007e\u007e\u007e';
return api.postWithToken( 'csrf', {
action: 'edit',
title: 'User talk:' + uploader,
section: 'new',
sectiontitle: section,
text: text,
summary: 'Notifying FPC nomination of [[:' + filePageName + ']] (via [[User:Wilfredor/fpc-archiver.js|fpc-archiver]])',
assert: 'user',
formatversion: 2
} );
}
function submitNomination( filePageName, fileTitle, user, gallery, comment, selfSupport, attribution, api ) {
var subpageTitle = 'Commons:Featured picture candidates/' + filePageName;
var content = buildNominationTemplate( filePageName, fileTitle, user, gallery, comment, selfSupport, attribution );
// Re-check rule 11 HERE, not only when the dialog opened. The FP dialog
// carries a gallery tree browser and a readiness panel, so users sit in
// it for minutes, long enough to file another nomination from a second
// tab, or for one to have been filed already. QIC and VIC both re-check
// at submit; FP was the exception. A failed read rejects rather than
// reading as "0 active nominations".
return fetchActiveNomsByUser( user, api ).then( function ( activeNoms ) {
var stillFar = activeNoms.filter( function ( n ) { return n.daysOld < 7; } );
if ( stillFar.length >= 2 ) {
throw new Error( 'FPC rule 11 caps active nominations: you already have ' +
stillFar.length + '. Wait until one closes (or is within 2 days of closing).' );
}
return api.postWithToken( 'csrf', {
action: 'edit',
title: subpageTitle,
text: content,
summary: 'Nominating for FP (via [[User:Wilfredor/commons-nominator.js|commons-nominator]])',
createonly: 1,
assert: 'user',
formatversion: 2
} );
} ).then( function () { return subpageTitle; } );
}
function addTransclusionToCandidateList( subpageTitle, api ) {
// The candidate list explicitly asks for new noms at the TOP of the
// section ("DO NOT ADD NEW NOMINATIONS RIGHT ABOVE THIS LINE, BUT AT
// THE TOP OF THIS SECTION"). Appending at end was the previous
// behaviour and put new noms below the line. Now: fetch the page,
// insert right after the section heading. Underscores in subpageTitle
// are normalised to spaces to match the convention used by every
// other transclusion in the list.
var pretty = subpageTitle.replace( /_/g, ' ' );
var insertLine = '\u007b{' + pretty + '}}';
return api.get( {
action: 'query', titles: 'Commons:Featured picture candidates/candidate list',
prop: 'revisions', rvprop: 'content|ids|timestamp', rvslots: 'main',
format: 'json', formatversion: 2, curtimestamp: 1
} ).then( function ( res ) {
var p = ( res && res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
var rev = ( p.revisions && p.revisions[ 0 ] ) || {};
var wt = ( rev.slots && rev.slots.main && rev.slots.main.content ) || '';
if ( !wt ) throw new Error( 'Could not read candidate list.' );
// Idempotency: if the transclusion is already there, leave the
// list alone (the postClose / archive flow may re-call this on
// an already-completed run).
// Build the dup matcher from the FULL subpage title (space/underscore
// tolerant). The previous version tried to strip the prefix with a
// regex that expected an escaped slash and never matched, so it
// doubled the prefix and the idempotency check never fired.
var dupRe = new RegExp( '\\{\\{\\s*' +
pretty.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ).replace( /[ _]/g, '[ _]' ) +
'\\s*\\}\\}' );
if ( dupRe.test( wt ) ) return { skipped: true };
// Insert immediately after the section heading "== Featured
// picture candidates ==" so the new nom is the FIRST entry.
var headingRe = /(== *Featured picture candidates *==\s*\n)/;
var newText;
if ( headingRe.test( wt ) ) {
// Replacer function: insertLine embeds the file name, which may
// contain "$" sequences that a replacement string would expand.
newText = wt.replace( headingRe, function ( m, heading ) { return heading + insertLine + '\n'; } );
} else {
// Fallback: prepend at the very top (safer than appending at
// the end where the "DO NOT ADD" comment lives).
newText = insertLine + '\n' + wt;
}
return api.postWithToken( 'csrf', {
action: 'edit',
title: 'Commons:Featured picture candidates/candidate list',
text: newText,
summary: 'Adding [[' + pretty + ']] to candidate list ' +
'(via [[User:Wilfredor/commons-nominator.js|commons-nominator]])',
// Pin the edit. Without this a nomination filed by someone else
// between our read and this write is silently overwritten, its
// transclusion vanishes while its subpage lives on. Every other
// list writer here (QIC, VIC, en.wp) already pins; this was the
// one that didn't.
baserevid: rev.revid,
basetimestamp: rev.timestamp,
starttimestamp: res.curtimestamp,
nocreate: 1,
assert: 'user',
formatversion: 2
} );
} );
}
function buildNominationTemplate( filePageName, fileTitle, user, gallery, comment, selfSupport, attribution ) {
// wgPageName arrives with underscores; the canonical FPC nomination
// template uses spaces everywhere so the heading renders as
// \"File:Name with spaces.jpg\" rather than \"File:Name_with_β¦\".
var pretty = filePageName.replace( /_/g, ' ' );
var subpage = 'Commons:Featured picture candidates/' + pretty;
var nineDaysSecs = 9 * 24 * 3600;
var endTs = Math.floor( Date.now() / 1000 ) + nineDaysSecs;
var sig = '--\u007e\u007e\u007e\u007e';
var infoParts = [ '\u007b{Info}}' ];
if ( comment ) infoParts.push( comment );
if ( attribution ) infoParts.push( attribution );
infoParts.push( sig );
// Standard FPC nomination format used by every other open nom (the
// wpImageAnnotator wrapper makes the image render centred + enables
// the image-notes gadget; without it the image floats left and the
// page looks broken). FPVotingPeriodFlag carries the unix timestamp
// when the 9-day voting period ends, FPCBot reads this for its
// expiry checks.
var lines = [];
lines.push( '===[[:' + pretty + ']]===' );
lines.push(
'\u007b{FPVotingPeriodFlag|' + endTs + '}}' +
'<small>Voting period ends on <b>' + new Date( endTs * 1000 ).toUTCString().replace( / GMT$/, ' (UTC)' ) +
'</b> (unless closed by the [[COM:FPC#5th-day|5th-day rule]])</small><br>' +
"<small>Voters ''must'' check: [[COM:FN|File name]]\u007b{Β·}}[[COM:IG|Quality]]\u007b{Β·}}" +
'[[Template:Information#Template parameters|Image description]]\u007b{Β·}}' +
'[[Commons:Copyright tags|License]]\u007b{Β·}}[[COM:CAT#Categorization tips|Categories]] ' +
'(what, where, who, when)</small><br>'
);
lines.push(
'Visit the [[' + subpage + '|nomination page]] to add or modify ' +
'[[Help:Gadget-ImageAnnotator|image notes]].'
);
lines.push( '<div class="wpImageAnnotatorEnable">' );
lines.push(
'<span class="wpImageAnnotatorPageName" style="display:none;">' +
'Featured picture candidates/' + pretty + '</span>'
);
lines.push(
'<span class="wpImageAnnotatorFullName" style="display:none;">' +
subpage + '</span>'
);
lines.push(
'<div class="wpImageAnnotatorFile">[[' + pretty + '|500x320px|' +
escapeAttr( fileTitle ) + ']]</div>'
);
lines.push(
'<div style="display:none;"><div><div>' +
'<!--Dummy marker to have image notes inserted below this line-->' +
'\u007b{ImageNoteEnd|id=-1}}'
);
lines.push( '</div>' );
lines.push( '</div>' );
lines.push( '' );
if ( gallery ) {
lines.push( "*'''Gallery:''' [[Commons:Featured pictures/" + gallery + ']]' );
} else {
lines.push( "*'''Gallery:''' <!-- add gallery link, e.g. [[Commons:Featured pictures/Plants/Asterales#Subfamily : Asteroideae]] -->" );
}
lines.push( '*' + infoParts.join( ' ' ) );
if ( selfSupport ) {
lines.push( '*\u007b{Support}} ' + sig );
}
// Trailing newline matters: when this subpage is transcluded directly
// above the next nom on the candidate list, MediaWiki needs a hard
// line break between Cosmos\'s last `*\u007b{Support}} sig` line and the
// next nom\'s `===File:β¦===` heading. Without it, the following
// heading is parsed as inline content of Cosmos\'s last list item and
// the next nom\'s title disappears from the rendered FPC page.
return lines.join( '\n' ) + '\n';
}
// βββ Quality Image Candidates (QIC) βββββββββββββββββββββββββββββββββββββ
// QIC nomination = ONE gallery line under today's UTC date heading on a
// single shared list page. Hard rule: no more than 5 images per day per
// nominator. There is no per-nomination subpage and no open-nomination cap.
function qicTodayHeadingText() {
var d = new Date();
return MONTHS[ d.getUTCMonth() ] + ' ' + d.getUTCDate() + ', ' + d.getUTCFullYear();
}
// Whitespace-tolerant, line-anchored matcher for today's date heading.
// MediaWiki does NOT normalise heading spacing, so "==June 13, 2026=="
// and "== June 13, 2026 ==" both persist; an exact-spaced indexOf would
// miss a hand-spaced section, return 0 noms, and let the 5/day cap be
// silently exceeded (plus create a duplicate section). The date text is
// letters/digits/comma/space only, so it is regex-safe verbatim.
function qicTodayHeadingRe() {
return new RegExp( '^==[ \\t]*' + qicTodayHeadingText() + '[ \\t]*==[ \\t]*$', 'm' );
}
// True if the file already has a line on the QIC candidate list (open
// nomination or one awaiting QICbot archival).
//
// The namespace is OPTIONAL. The list is a gallery, where the File:
// prefix may be left off, and five of the 644 nomination lines live on
// 2026-08-27 were written that way. Requiring the prefix answered "not
// nominated" for every one of them, and that is the answer that lets a
// second nomination be posted over an open one.
function qicAlreadyNominated( wt, filePageName ) {
var name = filePageName.replace( /^File:/, '' );
var esc = name.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ).replace( /[ _]/g, '[ _]' );
var re = new RegExp(
'\\n\\s*(?:(?:File|Image)[ _]?:[ _]?)?' + esc + '\\s*\\|\\s*\\{\\{\\s*/', 'i' );
return re.test( '\n' + wt );
}
// Count the user's OWN nominations under today's (UTC) date section. Reads
// parameter 1 (the nominator signature) only, so the user's reviews of
// other people's nominations (which sign parameter 2) do NOT inflate the
// count and falsely block them.
function countQicNomsToday( wt, user ) {
var mh = qicTodayHeadingRe().exec( wt );
if ( !mh ) return 0;
var rest = wt.slice( mh.index + mh[ 0 ].length );
var nextSec = rest.search( /\n==[^=]/ );
var section = nextSec >= 0 ? rest.slice( 0, nextSec ) : rest;
var canon = canonUser( user );
var count = 0;
section.split( /\n/ ).forEach( function ( line ) {
// Count ANY relative subpage template (\u007b{/Nomination}}, \u007b{/Promotion}},
// \u007b{/Decline}}, \u007b{/Discuss}}, \u007b{/Withdrawn}}, future variantsβ¦) whose
// parameter 1 holds the user's signature. Enumerating verdict names
// drifts: a withdrawn-but-same-day nom keeps the nominator sig in
// param 1 and still counts toward the 5/day budget, so missing it
// would let the user post a 6th.
if ( !/\{\{\s*\/[A-Za-z]/.test( line ) ) return;
var p1 = firstTemplateParam1( line );
if ( p1 && fragmentHasUser( p1, canon ) ) count++;
} );
return count;
}
function setupQicNominateLink( api ) {
var pageName = mw.config.get( 'wgPageName' ) || '';
var user = mw.config.get( 'wgUserName' );
if ( !user || !/^File:/.test( pageName ) ) return;
var link = addFileActionLink( 'pt-qic-nominate', 'π
Nominate for QI',
'Nominate this file for Quality Image (max 5 per day)' );
if ( !link ) return;
// Soft styling hint once we know the assessment + own-work state. Grey
// the link out when the file is already a Quality Image OR is not the
// current user's own work (QIC is limited to your own work).
Promise.all( [
checkFileAssessments( pageName, api ),
checkFileOwnWork( pageName, user, api )
] ).then( function ( r ) {
var a = r[ 0 ], ow = r[ 1 ];
var li = link.closest( 'li' ) || link;
if ( a.isQI ) {
li.classList.add( 'cn-already', 'cn-disabled' );
link.setAttribute( 'aria-disabled', 'true' );
link.title = 'Inactive, this file is already a Quality Image' +
( a.qiCategory ? ' (in "' + a.qiCategory + '").' : '.' );
} else if ( ow && ow.isOwn === false ) {
li.classList.add( 'cn-notown', 'cn-disabled' );
link.setAttribute( 'aria-disabled', 'true' );
link.title = 'Inactive, Quality Image nominations are limited to your own work' +
ownWorkReason( ow );
}
} ).catch( function () {} );
link.addEventListener( 'click', function ( e ) {
e.preventDefault();
// Inactive when the file is already a Quality Image, bail out BEFORE
// any "Checkingβ¦"/network, so the link does nothing at all (like the
// FP link, which doesn't act on already-featured files).
if ( ( link.closest( 'li' ) || link ).classList.contains( 'cn-disabled' ) ) return;
var old = link.textContent;
qicNominateFile( pageName, user, api, function ( busy ) {
link.textContent = busy ? 'Checkingβ¦' : old;
link.style.pointerEvents = busy ? 'none' : '';
}, function ( why ) {
if ( why === 'already-qi' ) {
( link.closest( 'li' ) || link ).classList.add( 'cn-already', 'cn-disabled' );
}
} );
} );
}
// The QIC nomination, for any file, not just the one being looked at.
//
// Everything below the entry point was already written against a file name
// rather than against wgPageName, so the only thing tying a nomination to
// the current page was the link that started it. Lifting it out lets a
// second tool ask for the same nomination without carrying its own copy of
// the five-per-day cap, the duplicate check or the own-work rule. There is
// one owner of those rules and now there can be more than one caller.
//
// Every precondition is re-read from the live candidate list at the moment
// of the click, so a caller that has been showing a stale worklist for an
// hour cannot talk this into exceeding the cap.
//
// @param {string} pageName File:Something.jpg
// @param {string} user the nominator
// @param {Object} api mw.Api
// @param {Function} [busy] called with true while checking, false after
// @param {Function} [failed] called with a short reason when it will not run
function qicNominateFile( pageName, user, api, busy, failed, onDone ) {
function setBusy( b ) {
if ( busy ) busy( b );
}
function refuse( why, message ) {
if ( failed ) failed( why );
if ( message ) alert( message );
}
setBusy( true );
return Promise.all( [
checkFileAssessments( pageName, api ),
fetchPage( QIC_LIST, api ),
checkFileOwnWork( pageName, user, api )
] ).then( function ( r ) {
setBusy( false );
var assess = r[ 0 ];
var list = r[ 1 ];
var ow = r[ 2 ];
if ( assess.isQI ) {
refuse( 'already-qi', null );
return;
}
if ( ow && ow.isOwn === false ) {
refuse( 'not-own-work',
'Cannot nominate for Quality Image: QIC nominations are limited to ' +
'your own work' + ownWorkReason( ow ) );
return;
}
if ( !list || list.wt === null || list.wt === undefined ) {
// Refusing beats guessing. Reading a failed request as "0
// nominations today" is how the cap gets exceeded.
refuse( 'unreadable',
'Cannot nominate for Quality Image: the candidate list could not be read. ' +
'Please try again in a moment.' );
return;
}
if ( qicAlreadyNominated( list.wt, pageName ) ) {
refuse( 'already-nominated',
'This file already has an open QIC nomination on the candidate list.' );
return;
}
var todayCount = countQicNomsToday( list.wt, user );
if ( todayCount >= QIC_DAILY_CAP ) {
refuse( 'cap-reached',
'Cannot nominate. QIC allows no more than ' + QIC_DAILY_CAP +
' images per day per nominator, ' +
'and you already have ' + todayCount + ' under today\'s (UTC) date section.' );
return;
}
showQicDialog( pageName, user, todayCount, api, onDone );
} ).catch( function () {
setBusy( false );
refuse( 'error',
'Failed to check QIC preconditions (network or API error). Please retry in a moment.' );
} );
}
// onDone, when given, replaces the jump to the candidate list. A caller
// working through a list of files wants to stay on its list; the file page
// has nowhere to stay, so it still navigates.
function showQicDialog( filePageName, user, todayCount, api, onDone ) {
var fileTitle = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
var m = buildModal( 'Nominate <i>' + escapeAttr( fileTitle ) + '</i> for Quality Image' );
m.body.innerHTML =
'<section class="fpc-nominate-section">' +
' <div class="fpc-nominate-section-title">Short description</div>' +
' <textarea class="cn-qic-desc" placeholder="One short line shown next to the thumbnail, e.g. "Bell tower of the Church of San Fiz de Solovio, Santiago de Compostela (Spain)"."></textarea>' +
' <div class="cn-hint">Quality Image is assessed by a single reviewer against ' +
' <a href="https://commons.wikimedia.org/wiki/Commons:Image_guidelines" target="_blank">the image guidelines</a> ' +
' (technical quality: focus, exposure, composition). It is independent of FP and VI.</div>' +
'</section>' +
'<section class="fpc-nominate-section">' +
' <div class="cn-quota">You have <b>' + todayCount + ' / 5</b> QIC nominations under today\'s (UTC) date section.</div>' +
'</section>';
var descInput = m.body.querySelector( '.cn-qic-desc' );
descInput.focus();
// Prefill with a trimmed version of the file's English description so the
// nominator starts from real text instead of a blank box.
//
// The cut used to be 240 characters, which is not a cut: measured over
// the 155 real descriptions on the candidate list, the median is 64
// characters, the 75th is 80 and the 90th is 103, and only 2 per cent
// reach 240. So the old limit almost never fired and a file page's
// prose went in whole, three times longer than anything around it. At
// 120 it trims the 9 per cent that are genuinely long and leaves the
// rest alone, and because the trimmer prefers a sentence end past the
// half way mark, what usually survives is the first sentence, which is
// what a caption wants.
descInput.placeholder = 'Loading the file\'s English descriptionβ¦';
fetchEnglishDescription( filePageName, api ).then( function ( d ) {
descInput.placeholder = 'One short line shown next to the thumbnail.';
if ( d && !descInput.value.trim() ) {
descInput.value = truncateDescription( d, QIC_DESC_MAX );
descInput.setSelectionRange( descInput.value.length, descInput.value.length );
}
} );
m.submitBtn.addEventListener( 'click', function () {
var clean = sanitizeWikiField( descInput.value );
if ( !clean.ok ) {
m.status.innerHTML = '<span style="color:#b91c1c">The description has ' +
escapeAttr( clean.error ) + 'please fix it before submitting.</span>';
descInput.focus();
return;
}
m.submitBtn.disabled = true;
m.cancelBtn.disabled = true;
m.status.textContent = 'Adding to the QIC candidate listβ¦';
var line = buildQicLine( filePageName, clean.value, user );
insertQicLine( filePageName, line, user, api ).then( function () {
if ( onDone ) {
m.status.textContent = 'Nominated.';
setTimeout( function () {
m.close();
onDone( filePageName );
}, 600 );
return;
}
m.status.textContent = 'Done! Opening the QIC candidate listβ¦';
setTimeout( function () {
window.location.href = '/wiki/' + encodeURI( QIC_LIST.replace( / /g, '_' ) );
}, 1000 );
} ).catch( function ( err ) {
var msg = err && err.error ? ( err.error.code + ': ' + err.error.info )
: ( err && err.message ? err.message : String( err ) );
m.status.innerHTML = '<span style="color:#b91c1c"><b>Error:</b> ' + escapeAttr( msg ) + '</span>';
m.submitBtn.disabled = false;
m.cancelBtn.disabled = false;
} );
} );
}
// Build the gallery line. The description and the nominator signature are
// parameter 1; parameter 2 is left EMPTY for the reviewer's verdict. The
// signature \u007e\u007e\u007e\u007e is sent literally and expanded server-side by PST when
// this edit saves (the file source is escaped at upload so PST does not
// sign the .js page itself).
// `cleanDescription` MUST already be sanitised (balanced, top-level pipes
// neutralised) by sanitizeWikiField, the dialog does that and surfaces an
// error to the user on invalid input, so an unbalanced "}}" can never reach
// here and close \u007b{/Nomination}} early.
function buildQicLine( filePageName, cleanDescription, user ) {
var pretty = filePageName.replace( /_/g, ' ' );
var desc = ( cleanDescription || '' ).trim();
if ( !desc ) {
// Commons titles cannot contain { } [ ] | < > so the filename
// fallback is inherently safe to interpolate.
desc = pretty.replace( /^File:/, '' ).replace( /\.[^.]+$/, '' );
}
// Sign CLIENT-SIDE: a plain [[User:X|X]] link plus a UTC timestamp we
// format ourselves. We deliberately do NOT sign with tildes here. This
// nomination rewrites the whole candidate-list page with a `text=`
// edit, and on that edit the server pre-save transform was observed NOT
// to expand the signature tildes, so a literal five-tilde run was saved
// into the line (the nominator signature then showed as raw tildes
// instead of a timestamp). Formatting the timestamp ourselves makes the
// signature correct regardless of PST. A plain link (not the user's
// *custom* signature, whose nicknames / <span>s / int-message talk link
// render badly inside \u007b{/Nomination}} in a \u003cgallery mw-notalk> caption)
// is the QIC convention and is what the daily-count parser keys off.
var sig = '--[[User:' + user + '|' + user + ']] ' + qicSignatureTimestamp();
return pretty + '|\u007b{/Nomination|' + desc + ' ' + sig + ' |}}';
}
// Current UTC time in MediaWiki's signature format, e.g.
// "07:05, 6 July 2026 (UTC)". Zero-padded 2-digit hour and minute, day
// without a leading zero, full month name. Matches what a five-tilde
// timestamp expands to, so scripted QIC signatures read like hand-signed
// ones. (Uses the browser clock in UTC, as the date-section heading
// already does, so the timestamp and the section stay consistent.)
function qicSignatureTimestamp() {
var d = new Date();
function p2( n ) { return ( n < 10 ? '0' : '' ) + n; }
return p2( d.getUTCHours() ) + ':' + p2( d.getUTCMinutes() ) + ', ' +
d.getUTCDate() + ' ' + MONTHS[ d.getUTCMonth() ] + ' ' +
d.getUTCFullYear() + ' (UTC)';
}
// Fetch the file's ENGLISH description (extmetadata, language=en), as plain
// text (HTML stripped), used to prefill the QIC description field.
function fetchEnglishDescription( filePageName, api ) {
return api.get( {
action: 'query', prop: 'imageinfo', iiprop: 'extmetadata',
iiextmetadatalanguage: 'en', titles: filePageName, format: 'json', formatversion: 2
} ).then( function ( r ) {
var pages = r && r.query && r.query.pages;
var ii = pages && pages[ 0 ] && pages[ 0 ].imageinfo && pages[ 0 ].imageinfo[ 0 ];
var raw = ii && ii.extmetadata && ii.extmetadata.ImageDescription && ii.extmetadata.ImageDescription.value;
if ( !raw ) return '';
var tmp = document.createElement( 'div' );
tmp.innerHTML = raw;
return ( tmp.textContent || tmp.innerText || '' ).replace( /\s+/g, ' ' ).trim();
}, function () { return ''; } );
}
// The file's own (non-hidden) categories, offered as the VIC scope dropdown.
function fetchFileCategories( filePageName, api ) {
return api.get( {
action: 'query', prop: 'categories', clshow: '!hidden', cllimit: 'max',
titles: filePageName, format: 'json', formatversion: 2
} ).then( function ( r ) {
var p = r && r.query && r.query.pages && r.query.pages[ 0 ];
return ( ( p && p.categories ) || [] ).map( function ( c ) {
return c.title.replace( /^Category:/, '' );
} );
}, function () { return []; } );
}
// Trim a description to one short line, preferring a sentence boundary.
// The sentence end used to have to fall past the half way mark of the cut,
// which made sense when the cut was 240 and stops making sense at 120: a
// first sentence of 46 characters is a good caption and was being rejected
// for being early, leaving a word-cut fragment of a second sentence in its
// place. What the rule is really guarding against is a cut so short it says
// nothing, so it now guards on an absolute floor instead of a fraction.
var SENTENCE_FLOOR = 30;
function truncateDescription( text, max ) {
max = max || 240;
text = ( text || '' ).trim();
if ( text.length <= max ) return text;
var cut = text.slice( 0, max );
var sentEnd = Math.max( cut.lastIndexOf( '. ' ), cut.lastIndexOf( '! ' ), cut.lastIndexOf( '? ' ) );
if ( sentEnd >= SENTENCE_FLOOR ) return cut.slice( 0, sentEnd + 1 ).trim();
var sp = cut.lastIndexOf( ' ' );
return ( sp > 0 ? cut.slice( 0, sp ) : cut ).trim() + 'β¦';
}
// βββ Picture of the Day (POTD) ββββββββββββββββββββββββββββββββββββββββββ
// Per Commons:Picture of the day/Instructions, anyone may put a Featured
// Picture that was never POTD on the next FREE date. Three edits:
// 1. Template:Potd/<date> -> \u007b{Potd filename|1=<name>|...}}
// 2. Template:Potd/<date> (en) -> \u007b{Potd description|...}}
// 3. the file page -> \u007b{Picture of the day|year=|month=|day=}}
var POTD_PREFIX = 'Template:Potd/';
function potd2( n ) { return ( n < 10 ? '0' : '' ) + n; }
function potdDateStr( d ) {
return d.getUTCFullYear() + '-' + potd2( d.getUTCMonth() + 1 ) + '-' + potd2( d.getUTCDate() );
}
// The file name currently sitting in the slot ('' when empty or absent),
// normalised the way the page title is (underscores β spaces).
function potdFilenameOf( wt ) {
var m = /\{\{\s*Potd filename\s*\|([\s\S]*?)\}\}/i.exec( wt || '' );
if ( !m ) return '';
var body = m[ 1 ];
var nm = /(?:^|\|)\s*1\s*=\s*([\s\S]*?)(?=\||$)/.exec( body );
var p1 = nm ? nm[ 1 ] : body.split( '|' )[ 0 ];
return p1.replace( /<!--[\s\S]*?-->/g, '' ).replace( /_/g, ' ' ).trim();
}
// A Potd/<date> page whose filename slot is empty is free to fill.
function potdFilenameEmpty( wt ) {
var m = /\{\{\s*Potd filename\s*\|([\s\S]*?)\}\}/i.exec( wt || '' );
if ( !m ) return false;
var body = m[ 1 ];
var nm = /(?:^|\|)\s*1\s*=\s*([\s\S]*?)(?=\||$)/.exec( body );
var p1 = nm ? nm[ 1 ] : body.split( '|' )[ 0 ];
return p1.replace( /<!--[\s\S]*?-->/g, '' ).trim() === '';
}
// Scan forward (batched) for the first date with a free POTD slot. POTD is
// scheduled YEARS ahead on Commons (huge FP backlog, ~365 slots/year), so
// the first free date is often ~2 years out, scan wide enough to reach it.
function findNextFreePotdDate( api ) {
var BATCH = 50, MAX = 1100;
var base = new Date();
base.setUTCHours( 0, 0, 0, 0 );
var scanned = 0;
function batch() {
var dates = [];
for ( var i = 0; i < BATCH && scanned < MAX; i++, scanned++ ) {
dates.push( potdDateStr( new Date( base.getTime() + ( scanned + 1 ) * 86400000 ) ) );
}
if ( !dates.length ) return Promise.resolve( null );
var titles = dates.map( function ( ds ) { return POTD_PREFIX + ds; } );
return api.get( {
action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main',
titles: titles.join( '|' ), format: 'json', formatversion: 2
} ).then( function ( r ) {
var by = {};
( ( r.query && r.query.pages ) || [] ).forEach( function ( p ) {
by[ p.title ] = p.missing ? { missing: true } :
{ wt: ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots.main.content ) || '' };
} );
for ( var j = 0; j < dates.length; j++ ) {
var info = by[ POTD_PREFIX + dates[ j ] ] || { missing: true };
if ( info.missing || potdFilenameEmpty( info.wt ) ) return dates[ j ];
}
return batch();
} );
}
return batch();
}
function setupPotdLink( api ) {
var pageName = mw.config.get( 'wgPageName' ) || '';
var user = mw.config.get( 'wgUserName' );
if ( !user || !/^File:/.test( pageName ) ) return;
var link = addFileActionLink( 'pt-potd-set', 'π Make Picture of the Day',
'Schedule this featured picture as Picture of the Day on the next free date' );
if ( !link ) return;
var li = link.closest( 'li' ) || link;
li.style.display = 'none'; // only an FP that was never POTD qualifies
Promise.all( [ checkFileAssessments( pageName, api ), fetchPage( pageName, api ) ] ).then( function ( r ) {
var a = r[ 0 ], wt = ( r[ 1 ] && r[ 1 ].wt ) || '';
var wasPotd = /\{\{\s*Picture of the day\b/i.test( wt ) ||
( a.categories || [] ).some( function ( c ) { return /Picture of the day|Pictures of the day/i.test( c ); } );
if ( a.isFP && !wasPotd ) li.style.display = '';
} ).catch( function () {} );
link.addEventListener( 'click', function ( e ) {
e.preventDefault();
var old = link.textContent;
link.textContent = 'Finding a free date (POTD is booked ~2 yrs ahead)β¦';
link.style.pointerEvents = 'none';
findNextFreePotdDate( api ).then( function ( dateStr ) {
link.textContent = old; link.style.pointerEvents = '';
if ( !dateStr ) { alert( 'No free Picture of the Day slot found in the next ~3 years, the schedule is completely full that far out. Try again later.' ); return; }
showPotdDialog( pageName, user, dateStr, api );
} ).catch( function () {
link.textContent = old; link.style.pointerEvents = '';
alert( 'Failed to scan POTD dates (network or API error).' );
} );
} );
}
// βββ Category page: per-FP action links (POTD + en.wiki FP) βββββββββββββ
// One action bar per thumbnail, created on first use. The QI button is
// attached as soon as the assessments are known and the featured-picture
// buttons arrive later, after two more rounds of requests, so they have to
// land in the same container rather than in two stacked ones.
function qiBar( box ) {
if ( !box._cnBar ) {
box._cnBar = document.createElement( 'div' );
box._cnBar.className = 'cn-cat-actions';
box.appendChild( box._cnBar );
}
return box._cnBar;
}
function setupCategoryActions( api ) {
var user = mw.config.get( 'wgUserName' );
if ( !user ) return;
var boxes = document.querySelectorAll( '#mw-category-media li.gallerybox, .mw-category-generated li.gallerybox, ul.gallery li.gallerybox' );
if ( !boxes.length ) return;
var byTitle = {}, titles = [];
Array.prototype.forEach.call( boxes, function ( box ) {
var a = box.querySelector( 'a.mw-file-description, a[href*="/wiki/File:"]' );
if ( !a ) return;
var title = '';
try { title = decodeURIComponent( ( a.getAttribute( 'href' ) || '' ).replace( /^.*\/wiki\//, '' ) ).replace( /_/g, ' ' ); } catch ( e ) {}
if ( !/^File:/.test( title ) ) return;
if ( !byTitle[ title ] ) { byTitle[ title ] = box; titles.push( title ); }
} );
if ( !titles.length ) return;
// Identify which gallery files are Commons FPs (extmetadata Assessments).
var fpFiles = [];
var isQi = {};
var isPicture = {};
var chain = Promise.resolve();
for ( var i = 0; i < titles.length; i += 50 ) {
( function ( batch ) {
chain = chain.then( function () {
return api.get( { action: 'query', prop: 'imageinfo', iiprop: 'extmetadata|mediatype|size', iiextmetadatafilter: 'Assessments', titles: batch.join( '|' ), format: 'json', formatversion: 2 } ).then( function ( r ) {
( ( r.query && r.query.pages ) || [] ).forEach( function ( p ) {
var ii = p.imageinfo && p.imageinfo[ 0 ];
var ass = ii && ii.extmetadata && ii.extmetadata.Assessments && ii.extmetadata.Assessments.value;
// The field is a pipe separated list, "quality|featured|potd",
// so one request already answers both questions for every file
// on the page. Nothing extra is fetched for the QI button.
if ( ass && /quality/i.test( ass ) ) isQi[ p.title ] = true;
// Quality Image is a picture assessment with a size
// floor, and a category holds neither only pictures
// nor only large ones. Offering to nominate a two
// hour webm was the first thing this did on a real
// page.
//
// COM:IG: "Images should have at least 2 real
// megapixels of information (with the exception of
// animations, videos, and SVGs)". So a bitmap under
// two megapixels is a near certain fail and gets no
// button; an SVG is exempt from the floor and keeps
// one. A file whose dimensions did not come back is
// given the benefit of the doubt, because refusing
// on missing data would hide good pictures.
if ( ii && ii.mediatype === 'DRAWING' ) {
isPicture[ p.title ] = true;
} else if ( ii && ii.mediatype === 'BITMAP' ) {
isPicture[ p.title ] = !ii.width || !ii.height ||
( ii.width * ii.height ) >= QI_MIN_PIXELS;
}
if ( ass && /featured/i.test( ass ) ) fpFiles.push( p.title );
} );
}, function () {} );
} );
} )( titles.slice( i, i + 50 ) );
}
var capUsed = 0;
var applyCap = function () {};
function applyCapTo( qi ) {
var left = QIC_DAILY_CAP - capUsed;
if ( left > 0 ) {
qi.classList.remove( 'cn-cat-qi-off' );
qi.title = qi.getAttribute( 'data-cn-title' ) || qi.title;
return;
}
qi.classList.add( 'cn-cat-qi-off' );
qi.title = 'You have already used all ' + QIC_DAILY_CAP +
' Quality Image nominations for today (UTC). The count resets at 00:00 UTC.';
}
chain.then( function () {
// Every file on the page that is not already a Quality Image gets a
// nominate button on its own thumbnail. A featured picture without
// the quality assessment is the strong case, so that button is drawn
// differently rather than being the only one offered: on this user's
// own featured pictures the gap was 84 of 212.
//
// Whether a file is the current user's own work is NOT checked here.
// It costs one wikitext read per file and a category page can hold
// hundreds, so the check stays where it already is, inside
// qicNominateFile at the moment of the click, which refuses with a
// reason rather than guessing beforehand.
var qiButtons = [];
titles.forEach( function ( title ) {
if ( isQi[ title ] || !isPicture[ title ] ) return;
var box = byTitle[ title ];
if ( !box || box._cnQi ) return;
box._cnQi = true;
var isFp = fpFiles.indexOf( title ) !== -1;
var qi = document.createElement( 'a' );
qi.href = '#';
qi.className = 'cn-cat-link cn-cat-qi' + ( isFp ? ' cn-cat-qi-fp' : '' );
qi.textContent = isFp ? '\uD83C\uDD70 QI \u2605' : '\uD83C\uDD70 QI';
qi.title = isFp ?
'This is a Featured Picture and not yet a Quality Image. Nominate it for QI.' :
'Nominate this file for Quality Image';
qi.setAttribute( 'data-cn-title', qi.title );
qi.addEventListener( 'click', function ( e ) {
e.preventDefault();
if ( qi.classList.contains( 'cn-cat-qi-off' ) ) {
return;
}
qicNominateFile( title, user, api, function ( busy ) {
qi.style.pointerEvents = busy ? 'none' : '';
qi.style.opacity = busy ? '.5' : '';
}, function ( why ) {
// A refusal about this file rather than about the network
// takes the button away, so the same wrong click is not
// offered a second time.
if ( why === 'already-qi' || why === 'already-nominated' ||
why === 'not-own-work' ) {
qi.parentNode.removeChild( qi );
}
}, function () {
// Nominated. One slot fewer, and if that was the last
// one every other button on the page says so now rather
// than when it is clicked.
qi.parentNode.removeChild( qi );
capUsed++;
applyCap();
} );
} );
qiButtons.push( qi );
qiBar( box ).appendChild( qi );
} );
// The daily cap, shown before the click rather than discovered by it.
// One extra request, and only when there is at least one button on
// the page that it could apply to.
applyCap = function () {
qiButtons.forEach( applyCapTo );
};
if ( qiButtons.length ) {
fetchPage( QIC_LIST, api ).then( function ( list ) {
if ( !list || list.wt === null || list.wt === undefined ) return;
capUsed = countQicNomsToday( list.wt, user );
applyCap();
}, function () {} );
}
if ( !fpFiles.length ) return;
// For each FP gather, in parallel: (a) which en.wikipedia articles use
// it (ns 0), and (b) its file wikitext, so we can hide the en-FP link
// when it is ALREADY a Featured Picture on en.wp, and hide POTD when it
// was already Picture of the Day.
var usage = {}, already = {}; // already[title] = { enfp:Bool, potd:Bool }
var uchain = Promise.resolve();
for ( var j = 0; j < fpFiles.length; j += 50 ) {
( function ( batch ) {
uchain = uchain.then( function () {
return api.get( { action: 'query', prop: 'globalusage', titles: batch.join( '|' ), gulimit: 'max', guprop: 'namespace', format: 'json', formatversion: 2 } ).then( function ( r ) {
( ( r.query && r.query.pages ) || [] ).forEach( function ( p ) {
var arts = ( ( p.globalusage ) || [] ).filter( function ( u ) { return u.wiki === 'en.wikipedia.org' && String( u.ns ) === '0'; } ).map( function ( u ) { return String( u.title ).replace( /_/g, ' ' ); } );
arts = arts.filter( function ( v, k ) { return arts.indexOf( v ) === k; } );
if ( arts.length ) usage[ p.title ] = arts;
} );
}, function () {} );
} );
} )( fpFiles.slice( j, j + 50 ) );
}
var wchain = Promise.resolve();
for ( var w = 0; w < fpFiles.length; w += 50 ) {
( function ( batch ) {
wchain = wchain.then( function () {
return api.get( { action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', titles: batch.join( '|' ), format: 'json', formatversion: 2 } ).then( function ( r ) {
( ( r.query && r.query.pages ) || [] ).forEach( function ( p ) {
var rev = p.revisions && p.revisions[ 0 ];
var wt = ( rev && rev.slots && rev.slots.main && rev.slots.main.content ) || '';
already[ p.title ] = { enfp: isAlreadyEnFp( wt ), potd: isAlreadyPotd( wt ) };
} );
}, function () {} );
} );
} )( fpFiles.slice( w, w + 50 ) );
}
Promise.all( [ uchain, wchain ] ).then( function () {
fpFiles.forEach( function ( title ) {
var box = byTitle[ title ];
if ( !box || box._cnDone ) return;
box._cnDone = true;
var flag = already[ title ] || {};
var bar = qiBar( box );
if ( !flag.potd ) {
var potd = document.createElement( 'a' );
potd.href = '#'; potd.className = 'cn-cat-link'; potd.textContent = 'π POTD';
potd.title = 'Schedule this FP as Picture of the Day (next free date)';
potd.addEventListener( 'click', function ( e ) { e.preventDefault(); runPotdForFile( title, user, api, potd ); } );
bar.appendChild( potd );
}
var arts = usage[ title ];
if ( arts && arts.length && !flag.enfp ) {
var enfp = document.createElement( 'a' );
enfp.href = '#'; enfp.className = 'cn-cat-link cn-cat-enfp'; enfp.textContent = 'β en-FP';
enfp.title = 'Nominate as Featured Picture on English Wikipedia, used in: ' + arts.join( ', ' );
enfp.addEventListener( 'click', function ( e ) { e.preventDefault(); showEnFpDialog( title, user, arts, api ); } );
bar.appendChild( enfp );
} else if ( !flag.enfp && ( !arts || !arts.length ) ) {
// Not used on en.wp yet β help find articles so it can qualify.
var find = document.createElement( 'a' );
find.href = '#'; find.className = 'cn-cat-link cn-cat-find'; find.textContent = 'π Articles';
find.title = 'Find en.wikipedia articles where this FP could be placed (so it can later become an en-FP)';
find.addEventListener( 'click', function ( e ) { e.preventDefault(); showSuggestArticlesDialog( title, api ); } );
bar.appendChild( find );
}
// When it is already featured on en.wp / already POTD, show a
// quiet static badge instead of an actionable link.
if ( flag.enfp || flag.potd ) {
var done = [];
if ( flag.enfp ) done.push( 'β
en-FP' );
if ( flag.potd ) done.push( 'π POTD' );
var badge = document.createElement( 'span' );
badge.className = 'cn-cat-flag';
badge.textContent = done.join( ' Β· ' );
badge.title = 'Already ' + [ flag.enfp ? 'a Featured Picture on English Wikipedia' : '', flag.potd ? 'a Picture of the Day' : '' ].filter( Boolean ).join( ' and ' ) + 'nothing to do.';
bar.appendChild( badge );
}
} );
} );
} );
}
// Reusable POTD launcher (used by the File-page link and the category links).
function runPotdForFile( filePageName, user, api, linkEl ) {
var old = linkEl.textContent; linkEl.textContent = 'β¦'; linkEl.style.pointerEvents = 'none';
fetchPage( filePageName, api ).then( function ( d ) {
var wt = ( d && d.wt ) || '';
if ( /\{\{\s*Picture of the day\b/i.test( wt ) ) {
alert( 'This file is already scheduled as (or was) Picture of the Day.' );
linkEl.textContent = old; linkEl.style.pointerEvents = ''; return;
}
return findNextFreePotdDate( api ).then( function ( dateStr ) {
linkEl.textContent = old; linkEl.style.pointerEvents = '';
if ( !dateStr ) { alert( 'No free Picture of the Day slot found in the next ~3 years.' ); return; }
showPotdDialog( filePageName, user, dateStr, api );
} );
} ).catch( function () { linkEl.textContent = old; linkEl.style.pointerEvents = ''; alert( 'Failed (network/API error).' ); } );
}
// βββ Wikidata image (P18), propagate a FP to its depicted item ββββββββββ
// FPCBot tags the file and galleries but never pushes the picture to the
// depicted Wikidata item, whose P18 feeds infoboxes across ALL Wikipedias.
// Offered on FP files that have a "depicts" (P180) statement.
function fetchDepicts( pageid, api ) {
return api.get( { action: 'wbgetclaims', entity: 'M' + pageid, property: 'P180', format: 'json', formatversion: 2 } ).then( function ( r ) {
var cl = ( r.claims && r.claims.P180 ) || [];
cl.sort( function ( a, b ) { return ( b.rank === 'preferred' ? 1 : 0 ) - ( a.rank === 'preferred' ? 1 : 0 ); } );
return cl.map( function ( c ) { return c.mainsnak && c.mainsnak.datavalue && c.mainsnak.datavalue.value && c.mainsnak.datavalue.value.id; } ).filter( Boolean );
}, function () { return []; } );
}
function setupWikidataImageLink( api ) {
var pageName = mw.config.get( 'wgPageName' ) || '';
var user = mw.config.get( 'wgUserName' );
var pageid = mw.config.get( 'wgArticleId' );
if ( !user || !/^File:/.test( pageName ) || !pageid ) return;
var link = addFileActionLink( 'pt-wd-image', 'πΌ Set as Wikidata image',
'Use this featured picture as the image (P18) of its depicted Wikidata item' );
if ( !link ) return;
var li = link.closest( 'li' ) || link;
li.style.display = 'none'; // only FPs that depict something qualify
Promise.all( [ checkFileAssessments( pageName, api ), fetchDepicts( pageid, api ) ] ).then( function ( r ) {
if ( r[ 0 ].isFP && r[ 1 ].length ) li.style.display = '';
} ).catch( function () {} );
link.addEventListener( 'click', function ( e ) { e.preventDefault(); openWikidataImageDialog( pageName, pageid, api ); } );
}
function openWikidataImageDialog( pageName, pageid, api ) {
var filename = pageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
var m = buildModal( 'Set as Wikidata image, <i>' + escapeAttr( filename ) + '</i>' );
m.body.innerHTML = '<section class="fpc-nominate-section"><div class="cn-quota">Loading the depicted Wikidata itemsβ¦</div></section>';
if ( m.submitBtn ) m.submitBtn.style.display = 'none';
var wd = ( typeof mw.ForeignApi === 'function' ) ? new mw.ForeignApi( 'https://www.wikidata.org/w/api.php' ) : null;
fetchDepicts( pageid, api ).then( function ( qids ) {
if ( !qids.length ) { m.body.innerHTML = '<section class="fpc-nominate-section"><div class="cn-quota">No βdepictsβ (P180) statements on this file, add some first, then this can link them.</div></section>'; return; }
if ( !wd ) { m.body.innerHTML = '<section class="fpc-nominate-section"><div class="cn-quota">Cross-wiki API unavailable.</div></section>'; return; }
var lang = mw.config.get( 'wgUserLanguage' ) || 'en';
wd.get( { action: 'wbgetentities', ids: qids.join( '|' ), props: 'labels|claims', languages: lang + '|en', format: 'json', formatversion: 2 } ).then( function ( r ) {
var ents = r.entities || {};
var rows = qids.map( function ( q ) {
var e = ents[ q ] || {};
var lab = ( e.labels && ( e.labels[ lang ] || e.labels.en ) && ( e.labels[ lang ] || e.labels.en ).value ) || q;
return { q: q, label: lab, hasImage: !!( e.claims && e.claims.P18 && e.claims.P18.length ) };
} );
m.body.innerHTML = '<section class="fpc-nominate-section"><div class="cn-hint">Pick the item this picture best represents. Only items <b>without</b> an image can be set, a featured picture is a strong choice for an empty infobox image across every Wikipedia.</div><div class="cn-wd-rows"></div></section>';
var box = m.body.querySelector( '.cn-wd-rows' );
rows.forEach( function ( row ) {
var div = document.createElement( 'div' ); div.className = 'cn-wd-row';
var a = document.createElement( 'a' ); a.href = 'https://www.wikidata.org/wiki/' + row.q; a.target = '_blank'; a.rel = 'noopener'; a.textContent = row.label + ' (' + row.q + ')';
div.appendChild( a );
if ( row.hasImage ) { var s = document.createElement( 'span' ); s.className = 'cn-wd-has'; s.textContent = 'already has an image'; div.appendChild( s ); }
else {
var b = document.createElement( 'button' ); b.type = 'button'; b.className = 'cn-wd-set'; b.textContent = 'Set this FP as its image';
b.addEventListener( 'click', function () { setWikidataImage( wd, row.q, filename, b, m ); } );
div.appendChild( document.createTextNode( ' ' ) ); div.appendChild( b );
}
box.appendChild( div );
} );
}, function () { m.body.innerHTML = '<section class="fpc-nominate-section"><div class="cn-quota">Could not read Wikidata.</div></section>'; } );
} );
}
function setWikidataImage( wd, qid, filename, btn, m ) {
if ( !confirm( 'Set [[' + filename + ']] as the image (P18) of ' + qid + ' on Wikidata?\n\nThis updates infoboxes that use this item across all Wikipedias. Only do it if the picture clearly represents the subject. (You must be logged in to Wikidata.)' ) ) return;
btn.disabled = true; btn.textContent = 'Savingβ¦';
wd.postWithToken( 'csrf', {
action: 'wbcreateclaim', entity: qid, property: 'P18', snaktype: 'value',
value: JSON.stringify( filename ),
summary: 'Set image to a Commons featured picture, via [[:c:User:Wilfredor/commons-nominator.js]]',
format: 'json', formatversion: 2
} ).done( function ( res ) {
if ( res && res.success ) {
var ok = document.createElement( 'span' ); ok.className = 'cn-wd-done'; ok.textContent = ' β set';
if ( btn.parentNode ) btn.parentNode.replaceChild( ok, btn );
if ( m.status ) m.status.textContent = qid + ' now uses this image.';
} else { btn.disabled = false; btn.textContent = 'Set this FP as its image'; alert( 'Failed to set the image.' ); }
} ).fail( function ( code ) {
btn.disabled = false; btn.textContent = 'Set this FP as its image';
alert( 'Failed' + ( code ? ' (' + code + ')' : '' ) + 'are you logged in to Wikidata?' );
} );
}
// βββ English Wikipedia Featured Picture nomination (cross-wiki) ββββββββββ
var EN_FPC = 'Wikipedia:Featured picture candidates';
var EN_MONTHS = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ];
// Common paths under [[Wikipedia:Featured pictures/β¦]] for the category picker.
var EN_FP_CATS = [
'Animals/Birds', 'Animals/Insects', 'Animals/Mammals', 'Animals/Marine life',
'Animals/Reptiles and amphibians', 'Animals/Others',
'Artwork/Culture, entertainment, and lifestyle', 'Artwork/Paintings', 'Artwork/Sculptures', 'Artwork/Others',
'Culture, entertainment, and lifestyle/Currency', 'Culture, entertainment, and lifestyle/Others',
'Diagrams, drawings, and maps/Maps', 'Diagrams, drawings, and maps/Others',
'Engineering and technology/Machinery', 'Engineering and technology/Others',
'History/Events', 'History/Others',
'Natural phenomena/Weather', 'Natural phenomena/Others',
'People/Others', 'People/Portraits',
'Places/Architecture', 'Places/Interiors', 'Places/Natural',
'Plants/Flowers', 'Plants/Fungi', 'Plants/Others',
'Sciences/Astronomy', 'Sciences/Biology', 'Sciences/Geology', 'Sciences/Medicine',
'Space/Others',
'Vehicles/Air', 'Vehicles/Land', 'Vehicles/Water'
];
// Read the first \u007b{Assessments|β¦}} block's parameter list (inner text), or ''.
function assessmentsBody( wt ) {
var m = /\{\{\s*Assessments\b([^}]*)\}\}/i.exec( wt || '' );
return m ? m[ 1 ] : '';
}
// Is this file ALREADY a Featured Picture on English Wikipedia?
// Recorded on Commons via \u007b{Assessments|β¦|enwiki=1|β¦}} (or enwiki=<subpage>).
function isAlreadyEnFp( wt ) {
if ( !wt ) return false;
if ( /\|\s*enwiki\s*=\s*(?!0\b|no\b|false\b)[^|}\s][^|}]*/i.test( assessmentsBody( wt ) ) ) return true;
// Legacy standalone \u007b{Featured picture|wikipedia=enβ¦}} tag.
if ( /\{\{\s*Featured[ _]picture\b[^}]*\|\s*wikipedia\s*=\s*[^|}]*\ben\b/i.test( wt ) ) return true;
return false;
}
// Was this file ALREADY Picture of the Day? (scheduled or historic)
function isAlreadyPotd( wt ) {
if ( !wt ) return false;
if ( /\{\{\s*Picture of the day\b/i.test( wt ) ) return true;
if ( /\|\s*potd\s*=\s*(?!0\b|no\b|false\b)[^|}\s][^|}]*/i.test( assessmentsBody( wt ) ) ) return true;
return false;
}
function showEnFpDialog( filePageName, user, articles, api ) {
var name = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
var bare = name.replace( /\.[^.]+$/, '' );
var m = buildModal( 'English Wikipedia FP, <i>' + escapeAttr( name ) + '</i>' );
var catOptions = EN_FP_CATS.map( function ( c ) { return '<option value="' + escapeAttr( c ) + '"></option>'; } ).join( '' );
m.body.innerHTML =
'<section class="fpc-nominate-section"><div class="cn-quota cn-quota-warn">β This creates a nomination on <b>English Wikipedia</b> (cross-wiki), you must be logged in there. Nothing is saved until you confirm. Voting runs 9 days.</div></section>' +
'<div class="cn-enfp-grid">' +
' <div class="cn-enfp-thumb"><img alt="" /><div class="cn-enfp-thumb-cap"></div></div>' +
' <div class="cn-enfp-fields">' +
' <section class="fpc-nominate-section"><div class="fpc-nominate-section-title">Caption</div><textarea class="cn-enfp-caption" rows="2"></textarea></section>' +
' <section class="fpc-nominate-section"><div class="fpc-nominate-section-title">Reason / encyclopedic value <span class="fpc-nominate-section-hint">(required)</span></div><textarea class="cn-enfp-reason" rows="3" placeholder="Why it is FP-worthy AND the encyclopedic value it adds to the article(s)."></textarea></section>' +
' <section class="fpc-nominate-section"><div class="fpc-nominate-section-title">Articles it appears in</div><textarea class="cn-enfp-articles" rows="2"></textarea></section>' +
' <section class="fpc-nominate-section"><div class="fpc-nominate-section-title">FP category <span class="fpc-nominate-section-hint">(required)</span></div>' +
' <input class="cn-enfp-cat" type="text" list="cn-enfp-catlist" autocomplete="off" placeholder="e.g. Animals/Mammals">' +
' <datalist id="cn-enfp-catlist">' + catOptions + '</datalist>' +
' <div class="cn-hint">Path under <a href="https://en.wikipedia.org/wiki/Wikipedia:Featured_pictures" target="_blank" rel="noopener">Wikipedia:Featured pictures/</a>, start typing to pick one (e.g. <code>Places/Architecture</code>).</div></section>' +
' <section class="fpc-nominate-section"><div class="fpc-nominate-section-title">Creator</div><input class="cn-enfp-creator" type="text" placeholder="User:Name or a name"></section>' +
' </div>' +
'</div>' +
'<section class="fpc-nominate-section"><div class="fpc-nominate-section-title">Exact wikitext to be posted</div><pre class="cn-enfp-preview" aria-live="polite"></pre></section>';
var capI = m.body.querySelector( '.cn-enfp-caption' ), reaI = m.body.querySelector( '.cn-enfp-reason' ),
artI = m.body.querySelector( '.cn-enfp-articles' ), catI = m.body.querySelector( '.cn-enfp-cat' ), creI = m.body.querySelector( '.cn-enfp-creator' ),
preI = m.body.querySelector( '.cn-enfp-preview' ),
thumbImg = m.body.querySelector( '.cn-enfp-thumb img' ), thumbCap = m.body.querySelector( '.cn-enfp-thumb-cap' );
artI.value = ( articles || [] ).map( function ( a ) { return '[[' + a + ']]'; } ).join( ' ' );
capI.value = "'''Original''' β " + bare;
// Load a thumbnail + prefill caption fragment / creator from Commons metadata.
api.get( { action: 'query', prop: 'imageinfo', iiprop: 'extmetadata|user|url', iiurlwidth: 320, titles: filePageName, format: 'json', formatversion: 2 } ).then( function ( r ) {
var ii = ( ( ( r.query || {} ).pages || [] )[ 0 ] || {} ).imageinfo;
ii = ii && ii[ 0 ];
if ( !ii ) return;
if ( ii.thumburl ) { thumbImg.src = ii.thumburl; }
var em = ii.extmetadata || {};
var desc = em.ImageDescription && em.ImageDescription.value;
if ( desc ) { var tmp = document.createElement( 'div' ); tmp.innerHTML = desc; capI.value = "'''Original''' β " + ( tmp.textContent || '' ).replace( /\s+/g, ' ' ).trim().slice( 0, 200 ); }
var artist = em.Artist && em.Artist.value;
if ( artist ) { var t2 = document.createElement( 'div' ); t2.innerHTML = artist; creI.value = ( t2.textContent || '' ).replace( /\s+/g, ' ' ).trim().slice( 0, 60 ); }
else if ( ii.user ) creI.value = 'User:' + ii.user;
if ( thumbCap ) thumbCap.textContent = bare;
refreshPreview();
} ).catch( function () {} );
// Build the exact nomination wikitext from the current field values.
function buildNom() {
var end = Math.floor( Date.now() / 1000 ) + 9 * 24 * 3600;
var d = new Date( end * 1000 );
var ends = d.getUTCDate() + ' ' + EN_MONTHS[ d.getUTCMonth() ] + ' ' + d.getUTCFullYear();
var endsT = ( '0' + d.getUTCHours() ).slice( -2 ) + ':' + ( '0' + d.getUTCMinutes() ).slice( -2 ) + ':' + ( '0' + d.getUTCSeconds() ).slice( -2 );
var creator = creI.value.trim() || ( 'User:' + user );
var crLink = /^User:/i.test( creator ) ? '[[' + creator + '|' + creator.replace( /^User:/i, '' ) + ']]' : creator;
var now = new Date();
var monthCat = MONTHS[ now.getUTCMonth() ] + ' ' + now.getUTCFullYear();
return '===[[' + EN_FPC + '/' + name + '|' + bare + ']]===\n' +
'\u007b{FPCnom/VotingEnds|' + end + '}}<small>Voting period ends on <b>' + ends + ' </b> at <b>' + endsT + ' (UTC)</b></small>\n' +
'[\u005bFile:' + name + '|thumb|right|900x260px|' + ( capI.value.trim() || ( "'''Original''' β " + bare ) ) + ']]\n' +
';Reason:' + ( reaI.value.trim() || 'β¦' ) + '\n' +
';Articles in which this image appears:' + ( artI.value.trim() || '' ) + '\n' +
';FP category for this image:[[Wikipedia:Featured pictures/' + ( catI.value.trim() || 'β¦' ) + ']]\n' +
';Creator:' + crLink + '\n\n' +
"* '''Support as nominator''' β \u007e\u007e\u007e\u007e\n" +
// The float-clearing footer: without \u007b{clear}} the right-floated
// image overflows into the NEXT nomination on the FPC page. The
// marker + <noinclude> categories mirror the standard FPC layout.
'<!-- additional votes go above this line -->\n' +
'\u007b{clear}}\n' +
'<noinclude>[\u005bCategory:Featured picture nominations]] [\u005bCategory:Featured picture nominations/' + monthCat + ']]</noinclude>\n';
}
function refreshPreview() { if ( preI ) preI.textContent = buildNom(); }
[ capI, reaI, artI, catI, creI ].forEach( function ( el ) { el.addEventListener( 'input', refreshPreview ); } );
refreshPreview();
m.submitBtn.textContent = 'Post to en.wiki';
m.submitBtn.addEventListener( 'click', function () {
var reason = reaI.value.trim(), cat = catI.value.trim();
if ( !reason ) { m.status.innerHTML = '<span style="color:#b91c1c">A reason / EV is required.</span>'; reaI.focus(); return; }
if ( !cat ) { m.status.innerHTML = '<span style="color:#b91c1c">The FP category is required.</span>'; catI.focus(); return; }
// The live preview above already shows the EXACT wikitext, so the
// "Post to en.wiki" click is itself the confirmation, no native
// confirm() popup. Post, then close the dialog and notify success.
var nomText = buildNom();
m.submitBtn.disabled = true; m.cancelBtn.disabled = true;
m.status.textContent = 'Posting to English Wikipediaβ¦';
var url = 'https://en.wikipedia.org/wiki/' + encodeURIComponent( ( EN_FPC + '/' + name ).replace( / /g, '_' ) );
submitEnwikiFp( name, nomText ).then( function () {
m.close();
var link = document.createElement( 'a' );
link.href = url; link.target = '_blank'; link.rel = 'noopener';
link.textContent = bare;
var msg = document.createElement( 'span' );
msg.appendChild( document.createTextNode( 'β Nominated on English Wikipedia: ' ) );
msg.appendChild( link );
mw.notify( msg, { type: 'success', autoHide: false, title: 'Featured picture candidate' } );
} ).catch( function ( err ) {
m.status.innerHTML = '<span style="color:#b91c1c"><b>Error:</b> ' + escapeAttr( err && err.message ? err.message : ( err && err.error ? err.error.code + ': ' + err.error.info : String( err ) ) ) + '</span>';
m.submitBtn.disabled = false; m.cancelBtn.disabled = false;
} );
} );
}
function submitEnwikiFp( name, nomText ) {
if ( typeof mw.ForeignApi !== 'function' ) return Promise.reject( new Error( 'mw.ForeignApi unavailable.' ) );
var enApi = new mw.ForeignApi( 'https://en.wikipedia.org/w/api.php' );
var sub = EN_FPC + '/' + name;
// 1) create the nomination subpage (createonly, never overwrite).
return enApi.postWithToken( 'csrf', {
action: 'edit', title: sub, text: nomText,
summary: 'Nominating for [[WP:FPC|featured picture]] via [[:c:User:Wilfredor/commons-nominator.js]]',
createonly: 1, assert: 'user', formatversion: 2
} ).then( function () {
// 2) transclude it at the top of the Current-nominations group.
return enApi.get( { action: 'query', prop: 'revisions', rvprop: 'content|timestamp', rvslots: 'main', titles: EN_FPC, curtimestamp: 1, format: 'json', formatversion: 2 } ).then( function ( r ) {
var p = r && r.query && r.query.pages && r.query.pages[ 0 ];
var rev = p && p.revisions && p.revisions[ 0 ];
var wt = rev && rev.slots && rev.slots.main && rev.slots.main.content;
if ( !wt ) throw new Error( 'could not read ' + EN_FPC );
var tpl = '\u007b{' + sub + '}}\n';
if ( wt.indexOf( tpl.trim() ) >= 0 ) return null; // already transcluded
var anchorRe = /Place new nominations at the TOP of the group\s*-->/i;
var mm = anchorRe.exec( wt );
var newText;
if ( mm ) { var at = mm.index + mm[ 0 ].length; newText = wt.slice( 0, at ) + '\n' + tpl + wt.slice( at ); }
else { var fi = wt.search( /\{\{Wikipedia:Featured picture candidates\// ); if ( fi < 0 ) throw new Error( 'could not locate the nominations list' ); newText = wt.slice( 0, fi ) + tpl + wt.slice( fi ); }
return enApi.postWithToken( 'csrf', {
action: 'edit', title: EN_FPC, text: newText,
summary: 'Adding [[' + sub + ']] to the featured picture candidates',
basetimestamp: rev.timestamp, starttimestamp: r.curtimestamp, nocreate: 1, assert: 'user', formatversion: 2
} );
} );
} );
}
// βββ Discover en.wp articles where a FP could be placed βββββββββββββββββ
// For a FP not yet used on English Wikipedia, list candidate articles so the
// user can add the image and later nominate it as an en.wp Featured Picture.
// Candidates come from two complementary sources:
// β’ the file's Wikidata "depicts" (P180) items β their en.wp sitelinks
// (the most on-topic, an article ABOUT what the picture shows), and
// β’ a full-text search on en.wp built from the filename + description.
// Articles whose infobox still has no image are flagged as the best targets.
var EN_STOPWORDS = /^(the|and|for|with|from|near|into|onto|over|under|this|that|these|those|its|his|her|their|los|las|del|una|uno|por|para|con|und|der|die|das|von|dem|des|sur|dans|avec|une)$/i;
// Distinct significant words (β₯3 chars, non-stopword), in order of appearance.
function queryTerms( bare, desc ) {
var seen = {}, words = [];
( bare + ' ' + ( desc || '' ) ).replace( /\([^)]*\)/g, ' ' )
.split( /[^A-Za-zΓ-ΓΏ]+/ ).forEach( function ( w ) {
if ( w.length < 3 || EN_STOPWORDS.test( w ) ) return;
var k = w.toLowerCase();
if ( seen[ k ] ) return; seen[ k ] = 1; words.push( w );
} );
return words;
}
function buildSearchQuery( bare, desc ) { return queryTerms( bare, desc ).slice( 0, 8 ).join( ' ' ); }
// Relevance gate: does an article title share β₯1 significant word with the
// file's own terms? Keeps text-search noise (loosely-matching articles) out.
function titleShares( title, termSet ) {
var words = String( title ).split( /[^A-Za-zΓ-ΓΏ]+/ );
for ( var i = 0; i < words.length; i++ ) {
var w = words[ i ];
if ( w.length >= 3 && !EN_STOPWORDS.test( w ) && termSet[ w.toLowerCase() ] ) return true;
}
return false;
}
function flagEmptyInfobox( cands, enApi ) {
if ( !enApi || !cands.length ) return Promise.resolve();
var titles = cands.map( function ( c ) { return c.title; } );
var chain = Promise.resolve();
for ( var i = 0; i < titles.length; i += 40 ) {
( function ( batch ) {
chain = chain.then( function () {
return enApi.get( { action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', titles: batch.join( '|' ), format: 'json', formatversion: 2 } ).then( function ( r ) {
var wtByTitle = {};
( ( r.query && r.query.pages ) || [] ).forEach( function ( p ) {
var rev = p.revisions && p.revisions[ 0 ];
wtByTitle[ String( p.title ).replace( /_/g, ' ' ) ] = ( rev && rev.slots && rev.slots.main && rev.slots.main.content ) || '';
} );
cands.forEach( function ( c ) {
if ( !( c.title in wtByTitle ) ) return;
var wt = wtByTitle[ c.title ];
c.hasInfobox = /\{\{\s*Infobox/i.test( wt );
var mimg = /\|\s*image(?:_?file|_?name)?\s*=\s*([^\n|]*)/i.exec( wt );
c.emptyImage = c.hasInfobox && !( mimg && mimg[ 1 ].trim() );
} );
}, function () {} );
} );
} )( titles.slice( i, i + 40 ) );
}
return chain.then( function () {
cands.sort( function ( a, b ) { return ( b.emptyImage ? 1 : 0 ) - ( a.emptyImage ? 1 : 0 ); } );
} );
}
function suggestionRow( c ) {
var row = document.createElement( 'div' ); row.className = 'cn-sugg-row' + ( c.emptyImage ? ' cn-sugg-good' : '' );
var enc = encodeURIComponent( c.title.replace( / /g, '_' ) );
var head = document.createElement( 'div' ); head.className = 'cn-sugg-head';
var a = document.createElement( 'a' ); a.href = 'https://en.wikipedia.org/wiki/' + enc; a.target = '_blank'; a.rel = 'noopener'; a.textContent = c.title; a.className = 'cn-sugg-title';
head.appendChild( a );
var src = document.createElement( 'span' ); src.className = 'cn-sugg-src cn-sugg-src-' + c.src; src.textContent = c.why; head.appendChild( src );
if ( c.emptyImage ) { var g = document.createElement( 'span' ); g.className = 'cn-sugg-badge'; g.textContent = 'empty infobox image'; head.appendChild( g ); }
else if ( c.illustrated ) { var g2 = document.createElement( 'span' ); g2.className = 'cn-sugg-badge cn-sugg-badge-muted'; g2.textContent = 'already illustrated'; head.appendChild( g2 ); }
row.appendChild( head );
if ( c.note ) { var n = document.createElement( 'div' ); n.className = 'cn-sugg-note'; n.textContent = c.note; row.appendChild( n ); }
var edit = document.createElement( 'a' ); edit.href = 'https://en.wikipedia.org/w/index.php?title=' + enc + '&action=edit'; edit.target = '_blank'; edit.rel = 'noopener'; edit.textContent = 'Open editor β'; edit.className = 'cn-sugg-link';
row.appendChild( edit );
return row;
}
function renderSuggestions( name, cands, quota, listEl ) {
// An article "already illustrated" = has an infobox that already carries an
// image. Those are poor targets, so hide them behind a toggle by default;
// articles with an empty infobox image or no infobox stay visible.
cands.forEach( function ( c ) { c.illustrated = ( c.hasInfobox === true && !c.emptyImage ); } );
var primary = cands.filter( function ( c ) { return !c.illustrated; } );
var hidden = cands.filter( function ( c ) { return c.illustrated; } );
var good = primary.filter( function ( c ) { return c.emptyImage; } ).length;
quota.innerHTML = 'Found <b>' + primary.length + '</b> candidate article' + ( primary.length === 1 ? '' : 's' ) +
( good ? '<b>' + good + '</b> with an empty infobox image (best targets).' : '.' ) +
' Add the picture to a fitting one, then reload this page, the β en-FP action appears once it is used in an article.';
// Ready-to-paste file snippet.
var snip = document.createElement( 'div' ); snip.className = 'cn-sugg-snip';
var inp = document.createElement( 'input' ); inp.type = 'text'; inp.readOnly = true;
inp.value = '[\u005bFile:' + name + '|thumb|' + name.replace( /\.[^.]+$/, '' ) + ']]';
var copy = document.createElement( 'button' ); copy.type = 'button'; copy.className = 'cn-sugg-copy'; copy.textContent = 'Copy';
copy.addEventListener( 'click', function () {
inp.select();
var done = function () { copy.textContent = 'Copied β'; setTimeout( function () { copy.textContent = 'Copy'; }, 1500 ); };
if ( navigator.clipboard && navigator.clipboard.writeText ) { navigator.clipboard.writeText( inp.value ).then( done, function () { try { document.execCommand( 'copy' ); done(); } catch ( e ) {} } ); }
else { try { document.execCommand( 'copy' ); done(); } catch ( e ) {} }
} );
snip.appendChild( inp ); snip.appendChild( copy );
listEl.appendChild( snip );
if ( !primary.length ) {
var none = document.createElement( 'div' ); none.className = 'cn-sugg-note';
none.textContent = hidden.length ? 'Every candidate already has an infobox image, see the ' + hidden.length + ' below.' : 'No article candidates without an image were found.';
listEl.appendChild( none );
}
primary.forEach( function ( c ) { listEl.appendChild( suggestionRow( c ) ); } );
if ( hidden.length ) {
var wrap = document.createElement( 'div' ); wrap.className = 'cn-sugg-hidden'; wrap.style.display = 'none';
hidden.forEach( function ( c ) { wrap.appendChild( suggestionRow( c ) ); } );
var toggle = document.createElement( 'a' ); toggle.href = '#'; toggle.className = 'cn-sugg-toggle';
toggle.textContent = 'Show ' + hidden.length + ' already-illustrated article' + ( hidden.length === 1 ? '' : 's' ) + ' βΎ';
toggle.addEventListener( 'click', function ( e ) {
e.preventDefault();
var open = wrap.style.display === 'none';
wrap.style.display = open ? '' : 'none';
toggle.textContent = ( open ? 'Hide ' : 'Show ' ) + hidden.length + ' already-illustrated article' + ( hidden.length === 1 ? '' : 's' ) + ( open ? ' β΄' : ' βΎ' );
} );
listEl.appendChild( toggle );
listEl.appendChild( wrap );
}
}
function showSuggestArticlesDialog( fileTitle, api ) {
var name = fileTitle.replace( /^File:/, '' ).replace( /_/g, ' ' );
var bare = name.replace( /\.[^.]+$/, '' );
var m = buildModal( 'Find en.wp articles, <i>' + escapeAttr( name ) + '</i>' );
if ( m.submitBtn ) m.submitBtn.style.display = 'none';
m.body.innerHTML = '<section class="fpc-nominate-section"><div class="cn-quota">Looking for English Wikipedia articles this picture could illustrateβ¦</div></section><div class="cn-sugg-list"></div>';
var listEl = m.body.querySelector( '.cn-sugg-list' );
var quota = m.body.querySelector( '.cn-quota' );
var enApi = ( typeof mw.ForeignApi === 'function' ) ? new mw.ForeignApi( 'https://en.wikipedia.org/w/api.php' ) : null;
var wd = ( typeof mw.ForeignApi === 'function' ) ? new mw.ForeignApi( 'https://www.wikidata.org/w/api.php' ) : null;
api.get( { action: 'query', titles: fileTitle, prop: 'info|imageinfo', iiprop: 'extmetadata', iiextmetadatafilter: 'ImageDescription', format: 'json', formatversion: 2 } ).then( function ( r ) {
var p = ( ( r.query || {} ).pages || [] )[ 0 ] || {};
var pageid = p.pageid;
var ii = p.imageinfo && p.imageinfo[ 0 ];
var descHtml = ii && ii.extmetadata && ii.extmetadata.ImageDescription && ii.extmetadata.ImageDescription.value;
var descTxt = '';
if ( descHtml ) { var t = document.createElement( 'div' ); t.innerHTML = descHtml; descTxt = ( t.textContent || '' ).replace( /\s+/g, ' ' ).trim(); }
var depictsP = ( pageid && wd ) ? fetchDepicts( pageid, api ).then( function ( qids ) {
if ( !qids.length ) return [];
return wd.get( { action: 'wbgetentities', ids: qids.join( '|' ), props: 'labels|descriptions|sitelinks', languages: 'en', sitefilter: 'enwiki', format: 'json', formatversion: 2 } ).then( function ( rr ) {
var ents = rr.entities || {};
return qids.map( function ( q ) {
var e = ents[ q ] || {};
var sl = e.sitelinks && e.sitelinks.enwiki && e.sitelinks.enwiki.title;
if ( !sl ) return null;
var lab = ( e.labels && e.labels.en && e.labels.en.value ) || sl;
var edesc = ( e.descriptions && e.descriptions.en && e.descriptions.en.value ) || '';
return { title: sl, why: 'depicts β' + lab + 'β', note: edesc, src: 'depicts' };
} ).filter( Boolean );
}, function () { return []; } );
} ) : Promise.resolve( [] );
var terms = queryTerms( bare, descTxt );
var termSet = {}; terms.forEach( function ( w ) { termSet[ w.toLowerCase() ] = 1; } );
var q = terms.slice( 0, 8 ).join( ' ' );
var searchP = ( enApi && q ) ? enApi.get( { action: 'query', list: 'search', srsearch: q, srnamespace: 0, srlimit: 12, srprop: 'snippet', format: 'json', formatversion: 2 } ).then( function ( rr ) {
return ( ( rr.query && rr.query.search ) || [] )
// Relevance gate: keep only results whose title shares a word
// with the file, drops loosely-matching full-text noise.
.filter( function ( s ) { return titleShares( s.title, termSet ); } )
.map( function ( s ) {
var snip = ( s.snippet || '' ).replace( /<[^>]+>/g, '' ).replace( /&[a-z]+;/g, ' ' ).replace( /\s+/g, ' ' ).trim();
return { title: s.title, why: 'text match', note: snip, src: 'search' };
} );
}, function () { return []; } ) : Promise.resolve( [] );
return Promise.all( [ depictsP, searchP ] ).then( function ( res ) {
var byTitle = {}, cands = [];
res[ 0 ].concat( res[ 1 ] ).forEach( function ( c ) {
var k = c.title.replace( /_/g, ' ' );
if ( byTitle[ k ] ) { if ( c.src === 'depicts' ) byTitle[ k ].why = c.why + ' Β· ' + byTitle[ k ].why; return; }
c.title = k; byTitle[ k ] = c; cands.push( c );
} );
if ( !cands.length ) { quota.textContent = 'No obvious article candidates found. Try adding a βdepictsβ statement on the file, then reopen this.'; return; }
return flagEmptyInfobox( cands, enApi ).then( function () { renderSuggestions( name, cands, quota, listEl ); } );
} );
} ).catch( function () { quota.textContent = 'Could not gather candidates (network/API error).'; } );
}
function showPotdDialog( filePageName, user, dateStr, api ) {
var fileTitle = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
var m = buildModal( 'Picture of the Day, <i>' + escapeAttr( fileTitle ) + '</i>' );
m.body.innerHTML =
'<section class="fpc-nominate-section">' +
' <div class="cn-quota">Next free date: <b>' + dateStr + '</b> (UTC)' +
( function () {
var days = Math.round( ( Date.parse( dateStr + 'T00:00:00Z' ) - Date.now() ) / 86400000 );
var months = Math.round( days / 30.4 );
return days > 60 ? 'about <b>' + months + ' months</b> from now (POTD is scheduled far ahead; this is the earliest empty slot).' : 'the slot is currently empty.';
}() ) +
'</div>' +
'</section>' +
'<section class="fpc-nominate-section">' +
' <div class="fpc-nominate-section-title">English description</div>' +
' <textarea class="cn-potd-desc"></textarea>' +
' <div class="cn-hint">Goes into \u007b{Potd description}}; also adds \u007b{Picture of the day}} to the file page. ' +
' <b>Tip:</b> like other POTD entries, link the key terms, <code>\u007b{w|Subject}}</code> or <code>[[:en:Place|Place]]</code>, and italicise titles/species (<code>\'\'β¦\'\'</code>); a plain line works but reads poorer. ' +
' Self-service per <a href="https://commons.wikimedia.org/wiki/Commons:Picture_of_the_day/Instructions" target="_blank">the POTD instructions</a>.</div>' +
'</section>';
var descInput = m.body.querySelector( '.cn-potd-desc' );
descInput.placeholder = 'Loading the file\'s English descriptionβ¦';
descInput.focus();
fetchEnglishDescription( filePageName, api ).then( function ( d ) {
descInput.placeholder = 'One or two sentences shown under the image.';
if ( d && !descInput.value.trim() ) descInput.value = truncateDescription( d, 300 );
} );
m.submitBtn.addEventListener( 'click', function () {
var desc = sanitizeWikiField( descInput.value );
if ( !desc.ok ) {
m.status.innerHTML = '<span style="color:#b91c1c">The description has ' + escapeAttr( desc.error ) + 'please fix it.</span>';
descInput.focus(); return;
}
if ( !desc.value ) {
m.status.innerHTML = '<span style="color:#b91c1c">Please add a short description.</span>';
descInput.focus(); return;
}
m.submitBtn.disabled = true; m.cancelBtn.disabled = true;
m.status.textContent = 'Scheduling Picture of the Day for ' + dateStr + 'β¦';
schedulePotd( filePageName, dateStr, desc.value, api ).then( function () {
m.status.textContent = 'Done! Opening the POTD templateβ¦';
setTimeout( function () {
window.location.href = '/wiki/' + encodeURIComponent( ( POTD_PREFIX + dateStr ).replace( / /g, '_' ) );
}, 1000 );
} ).catch( function ( err ) {
var msg = err && err.message ? err.message : ( err && err.error ? err.error.code + ': ' + err.error.info : String( err ) );
m.status.innerHTML = '<span style="color:#b91c1c"><b>Error:</b> ' + escapeAttr( msg ) + '</span>';
m.submitBtn.disabled = false; m.cancelBtn.disabled = false;
} );
} );
}
function schedulePotd( filePageName, dateStr, description, api ) {
var name = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
var parts = dateStr.split( '-' ), Y = parts[ 0 ], M = parts[ 1 ], D = parts[ 2 ];
var fnPage = POTD_PREFIX + dateStr;
var descPage = POTD_PREFIX + dateStr + ' (en)';
// 1) filename, re-check the slot is STILL free, then set it (never overwrite).
return fetchPage( fnPage, api ).then( function ( fp ) {
if ( !fp.missing && !potdFilenameEmpty( fp.wt ) ) {
// Our OWN name already in the slot means an earlier attempt got
// past this step and failed at the caption or the file page.
// Skip ahead instead of refusing. Otherwise the retry reports
// "filled by someone else", us, and the day stays scheduled
// with an image and no caption, unfixable from the UI.
if ( potdFilenameOf( fp.wt ) === name ) return null;
throw new Error( 'The slot for ' + dateStr + ' was just filled by someone else, reopen to pick the next free date.' );
}
var fnText = '\u007b{Potd filename|1=' + name +
'\n<!--DON\'T EDIT BELOW THIS LINE. IT FILLS OUT THE REST FOR YOU. \n-->|2=' + Y + '|3=' + M + '|4=' + D + '}}';
return api.postWithToken( 'csrf', {
action: 'edit', title: fnPage, text: fnText,
summary: 'Set Picture of the Day (' + dateStr + '): ' + name + ' via [[User:Wilfredor/commons-nominator.js|commons-nominator]]',
basetimestamp: fp.basetimestamp || undefined, baserevid: fp.baserevid || undefined,
assert: 'user', formatversion: 2
} );
} ).then( function () {
var dText = '\u007b{Potd description|1=' + description + '|2=en|3=' + Y + '|4=' + M + '|5=' + D + '}}';
// Read first so the write can be pinned: this was the only one of
// the three POTD edits with no conflict protection, so it would
// blindly overwrite a caption written between our steps.
return fetchPage( descPage, api ).then( function ( dp ) {
return api.postWithToken( 'csrf', {
action: 'edit', title: descPage, text: dText,
summary: 'POTD description (' + dateStr + ') via [[User:Wilfredor/commons-nominator.js|commons-nominator]]',
basetimestamp: dp.basetimestamp || undefined, baserevid: dp.baserevid || undefined,
assert: 'user', formatversion: 2
} );
} );
} ).then( function () {
return fetchPage( filePageName, api ).then( function ( fpage ) {
if ( /\{\{\s*Picture of the day\b/i.test( fpage.wt ) ) return null;
return api.postWithToken( 'csrf', {
action: 'edit', title: filePageName,
prependtext: '\u007b{Picture of the day|year=' + Y + '|month=' + M + '|day=' + D + '}}\n',
summary: 'Scheduled as Picture of the Day ' + dateStr + ' via [[User:Wilfredor/commons-nominator.js|commons-nominator]]',
basetimestamp: fpage.basetimestamp || undefined, baserevid: fpage.baserevid || undefined,
assert: 'user', formatversion: 2
} );
} );
} );
}
// Splice the new line into the candidate list. Re-reads fresh wikitext,
// re-checks the dup + 5/day guards against it (closing the precheckβsubmit
// race), pins baserevid/basetimestamp, and inserts at the TOP of today's
// gallery (creating today's date section if this is the day's first nom).
function insertQicLine( filePageName, line, user, api ) {
return fetchPage( QIC_LIST, api ).then( function ( data ) {
var wt = data.wt;
if ( qicAlreadyNominated( wt, filePageName ) ) {
throw new Error( 'This file already has an open QIC nomination.' );
}
var cnt = countQicNomsToday( wt, user );
if ( cnt >= 5 ) {
throw new Error( 'QIC daily limit reached (5/day). You already have ' + cnt +
' under today\'s date section.' );
}
var heading = '== ' + qicTodayHeadingText() + ' ==';
var newText;
var mh = qicTodayHeadingRe().exec( wt );
if ( mh ) {
var secStart = mh.index + mh[ 0 ].length;
var nextSec = wt.slice( secStart ).search( /\n==[^=]/ );
var secEnd = nextSec >= 0 ? secStart + nextSec : wt.length;
// Tolerate gallery attributes (e.g. "\u003cgallery mode=packed>").
var galRe = /\u003cgallery\b[^>]*>/g;
galRe.lastIndex = secStart;
var gm = galRe.exec( wt );
if ( !gm || gm.index >= secEnd ) {
throw new Error( "Could not find today's \u003cgallery> block." );
}
var insertAt = gm.index + gm[ 0 ].length;
newText = wt.slice( 0, insertAt ) + '\n' + line + wt.slice( insertAt );
} else {
var block = '\n\n' + heading + '\n\u003cgallery>\n' + line + '\n\u003c/gallery>\n';
var anchor = 'new nominations -->';
var aIdx = wt.indexOf( anchor );
if ( aIdx >= 0 ) {
var after = aIdx + anchor.length;
newText = wt.slice( 0, after ) + block + wt.slice( after );
} else {
var nomH = wt.search( /^=\s*Nominations\s*=/m );
if ( nomH < 0 ) throw new Error( 'Could not locate the QIC nominations section.' );
var nl = wt.indexOf( '\n', nomH );
newText = wt.slice( 0, nl + 1 ) + block + wt.slice( nl + 1 );
}
}
return api.postWithToken( 'csrf', {
action: 'edit',
title: QIC_LIST,
text: newText,
summary: 'Nominating [[:' + filePageName.replace( /_/g, ' ' ) +
']] for QI (via [[User:Wilfredor/commons-nominator.js|commons-nominator]])',
baserevid: data.baserevid,
basetimestamp: data.basetimestamp,
nocreate: 1,
assert: 'user',
formatversion: 2
} );
} );
}
// βββ Valued Image Candidates (VIC) ββββββββββββββββββββββββββββββββββββββ
// VIC nomination = create a per-file subpage with \u007b{VIC}} (scope required),
// then append the bare filename to the \u007b{VICs}} list. No daily/open cap.
// Per-scope uniqueness ("most valued image of its kind") is a reviewer
// judgement; we surface it as a reminder, not a hard block.
function vicAlreadyNominated( wt, filePageName ) {
var name = filePageName.replace( /^File:/, '' );
var esc = name.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ).replace( /[ _]/g, '[ _]' );
return new RegExp( '\\n\\s*\\|\\s*' + esc + '\\s*(?:\\n|$)', 'i' ).test( '\n' + wt );
}
function setupVicNominateLink( api ) {
var pageName = mw.config.get( 'wgPageName' ) || '';
var user = mw.config.get( 'wgUserName' );
if ( !user || !/^File:/.test( pageName ) ) return;
var link = addFileActionLink( 'pt-vic-nominate', 'β Nominate for VI',
'Nominate this file for Valued Image' );
if ( !link ) return;
var assessState = checkFileAssessments( pageName, api );
assessState.then( function ( a ) {
if ( a.isVI ) {
// VI is per-scope, so being a VI does NOT block a nomination for a
// DIFFERENT scope, the link stays active; the tooltip just informs.
( link.closest( 'li' ) || link ).classList.add( 'cn-already' );
link.title = 'Already a Valued Image, VI is per-scope, so you can still nominate it for a different scope.';
}
} ).catch( function () {} );
link.addEventListener( 'click', function ( e ) {
e.preventDefault();
var old = link.textContent;
link.textContent = 'Checkingβ¦';
link.style.pointerEvents = 'none';
Promise.all( [ assessState, fetchPage( VIC_LIST, api ) ] ).then( function ( r ) {
link.textContent = old;
link.style.pointerEvents = '';
var assess = r[ 0 ];
var list = r[ 1 ];
if ( vicAlreadyNominated( list.wt, pageName ) ) {
alert( 'This file already has an open VIC nomination on the candidate list.' );
return;
}
// Already a VI? No popup, the tooltip already explains it is
// per-scope; proceed straight to the dialog (new scope).
showVicDialog( pageName, user, api );
} ).catch( function () {
link.textContent = old;
link.style.pointerEvents = '';
alert( 'Failed to check VIC preconditions (network or API error). Please retry in a moment.' );
} );
} );
}
function showVicDialog( filePageName, user, api ) {
var fileTitle = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
var m = buildModal( 'Nominate <i>' + escapeAttr( fileTitle ) + '</i> for Valued Image' );
m.body.innerHTML =
'<section class="fpc-nominate-section">' +
' <div class="fpc-nominate-section-title">Scope <span class="fpc-nominate-section-hint">(required)</span></div>' +
' <select class="cn-vic-cat"><option value="">pick one of this file\'s categories to fill the scope</option></select>' +
' <textarea class="cn-vic-scope" placeholder="What this is the most valuable illustration OF, e.g. [[:Category:Foo|A short caption describing the subject]]"></textarea>' +
' <div class="cn-hint">The scope defines what the image illustrates. Only ONE image can be the ' +
' Valued Image of a given scope, so keep it specific. Please enable the ' +
' <a href="https://commons.wikimedia.org/wiki/Special:Preferences#mw-prefsection-gadgets" target="_blank">FastCCI gadget</a> ' +
' and check no similar-scope VI already exists.</div>' +
'</section>' +
'<section class="fpc-nominate-section">' +
' <div class="fpc-nominate-section-title">Orientation</div>' +
' <select class="cn-vic-orientation">' +
' <option value="landscape">landscape</option>' +
' <option value="portrait">portrait</option>' +
' <option value="panorama">panorama</option>' +
' </select>' +
'</section>' +
'<section class="fpc-nominate-section">' +
' <div class="fpc-nominate-section-title">Reason <span class="fpc-nominate-section-hint">(optional)</span></div>' +
' <textarea class="cn-vic-reason" placeholder="Why this image best illustrates the scope (optional)."></textarea>' +
'</section>' +
'<section class="fpc-nominate-section">' +
' <div class="fpc-nominate-section-title">Used in <span class="fpc-nominate-section-hint">(optional)</span></div>' +
' <textarea class="cn-vic-usedin" placeholder="Articles/pages that use this image, one per line."></textarea>' +
' <div class="cn-hint">If the image is already used in articles, listing them here <b>reinforces the value argument</b>. Auto-filled from the file\'s real usages, edit as you like. Not required to nominate.</div>' +
'</section>';
var scopeInput = m.body.querySelector( '.cn-vic-scope' );
var reasonInput = m.body.querySelector( '.cn-vic-reason' );
var orientInput = m.body.querySelector( '.cn-vic-orientation' );
var catSelect = m.body.querySelector( '.cn-vic-cat' );
var usedinInput = m.body.querySelector( '.cn-vic-usedin' );
// Auto-fill "used in" from the file's real article usages (GlobalUsage).
usedinInput.placeholder = 'Looking up where this image is usedβ¦';
fetchVicUsedIn( filePageName, api ).then( function ( list ) {
usedinInput.placeholder = 'Articles/pages that use this image, one per line.';
if ( list && !usedinInput.value.trim() ) usedinInput.value = list;
} );
// Prefill the reason with the standard VIC wording (still editable).
// Verified against promoted nominations: the reason is plain
// "best in scope", the category lives in the separate scope= field and
// is NOT repeated here.
reasonInput.value = 'best in scope';
// Populate the scope dropdown from the file's categories; picking one
// writes a ready-made [[:Category:X|X]] scope that stays editable.
fetchFileCategories( filePageName, api ).then( function ( cats ) {
cats.forEach( function ( c ) {
var o = document.createElement( 'option' );
o.value = c; o.textContent = c;
catSelect.appendChild( o );
} );
} );
catSelect.addEventListener( 'change', function () {
var c = catSelect.value;
if ( c ) {
scopeInput.value = '[[:Category:' + c + '|' + c + ']]';
// Standard VIC reason is plain "best in scope" (the scope itself
// is the scope= field, not repeated in the reason), keep it
// unless the nominator typed something custom.
if ( !reasonInput.value.trim() || /^best in scope\b/i.test( reasonInput.value.trim() ) ) {
reasonInput.value = 'best in scope';
}
}
scopeInput.focus();
} );
scopeInput.focus();
m.submitBtn.addEventListener( 'click', function () {
var scope = sanitizeWikiField( scopeInput.value );
if ( !scope.ok ) {
m.status.innerHTML = '<span style="color:#b91c1c">The scope has ' +
escapeAttr( scope.error ) + 'please fix it.</span>';
scopeInput.focus();
return;
}
if ( !scope.value ) {
m.status.innerHTML = '<span style="color:#b91c1c">A scope is required for a VIC nomination.</span>';
scopeInput.focus();
return;
}
var reason = sanitizeWikiField( reasonInput.value );
if ( !reason.ok ) {
m.status.innerHTML = '<span style="color:#b91c1c">The reason has ' +
escapeAttr( reason.error ) + 'please fix it.</span>';
reasonInput.focus();
return;
}
var usedin = sanitizeUsedIn( usedinInput.value );
if ( !usedin.ok ) {
m.status.innerHTML = '<span style="color:#b91c1c">The "used in" list has ' +
escapeAttr( usedin.error ) + 'please fix it.</span>';
usedinInput.focus();
return;
}
m.submitBtn.disabled = true;
m.cancelBtn.disabled = true;
m.status.textContent = 'Creating the VIC nomination subpageβ¦';
submitVic( filePageName, scope.value, reason.value, orientInput.value, usedin.value, api )
.then( function ( subpageTitle ) {
m.status.textContent = 'Done! Opening your nominationβ¦';
setTimeout( function () {
window.location.href = '/wiki/' + encodeURIComponent( subpageTitle );
}, 1000 );
} )
.catch( function ( err ) {
var msg = err && err.error ? ( err.error.code + ': ' + err.error.info )
: ( err && err.message ? err.message : String( err ) );
m.status.innerHTML = '<span style="color:#b91c1c"><b>Error:</b> ' + escapeAttr( msg ) + '</span>';
m.submitBtn.disabled = false;
m.cancelBtn.disabled = false;
} );
} );
}
// Canonical \u007b{VIC}} subpage. subst:SUBPAGENAME / subst:VI-time / \u007e\u007e\u007e are
// resolved server-side by PST when this edit saves, which reproduces the
// exact title encoding the site uses. The <noinclude>/<includeonly> switch
// makes the page render \u007b{VIC}} standalone and \u007b{VIC-thumb}} when the
// \u007b{VICs}} list transcludes it.
// `cleanScope` / `cleanReason` MUST already be sanitised by sanitizeWikiField
// (balanced braces/brackets, top-level pipes neutralised), the dialog does
// that and rejects invalid input, so the free text cannot close \u007b{VIC}}
// early or inject a stray template parameter.
// Light multi-line sanitiser for the "used in" list. Balance/neutralise each
// line via sanitizeWikiField but KEEP the line breaks (it is a bullet list).
function sanitizeUsedIn( s ) {
var lines = ( s || '' ).split( /\r?\n/ );
var out = [];
for ( var i = 0; i < lines.length; i++ ) {
if ( !lines[ i ].trim() ) continue;
var r = sanitizeWikiField( lines[ i ] );
if ( !r.ok ) return r;
out.push( r.value );
}
return { ok: true, value: out.join( '\n' ) };
}
// Build the "used in" bullet list from the file's GlobalUsage on content
// (article, ns 0) pages of Wikipedias, as [[:lang:Title]] interwiki links.
function fetchVicUsedIn( filePageName, api ) {
return api.get( {
action: 'query', prop: 'globalusage', titles: filePageName,
gulimit: 'max', guprop: 'namespace', format: 'json', formatversion: 2
} ).then( function ( r ) {
var p = r && r.query && r.query.pages && r.query.pages[ 0 ];
var gu = ( p && p.globalusage ) || [];
var out = [], seen = {};
gu.forEach( function ( u ) {
if ( String( u.ns ) !== '0' ) return; // article (content) namespace only
var mw_ = ( u.wiki || '' ).match( /^([a-z-]+)\.wikipedia\.org$/ ); // Wikipedias
if ( !mw_ ) return;
var link = '* [[:' + mw_[ 1 ] + ':' + String( u.title ).replace( /_/g, ' ' ) + ']]';
if ( !seen[ link ] ) { seen[ link ] = true; out.push( link ); }
} );
return out.slice( 0, 20 ).join( '\n' );
}, function () { return ''; } );
}
function buildVicSubpage( cleanScope, cleanReason, orientation, cleanUsedIn ) {
var orient = /^(landscape|portrait|panorama)$/.test( orientation ) ? orientation : 'landscape';
var sc = ( cleanScope || '' ).trim();
var rs = ( cleanReason || '' ).trim();
var L = [];
L.push( '<noinclude>=== \u007b{subst:SUBPAGENAME}} ===' );
L.push( '' );
L.push( '\u007b{VIC</noinclude><includeonly>\u007b{VIC-thumb</includeonly>' );
L.push( '|subpage=\u007b{subst:SUBPAGENAME}}' );
L.push( '|image=\u007b{subst:SUBPAGENAME}}' );
L.push( '|date=\u007b{subst:VI-time}}' );
L.push( '|nominator=\u007e\u007e\u007e' );
L.push( '|scope=' + sc );
L.push( '|orientation=' + orient + ' <!-- change to portrait or panorama if relevant -->' );
var ui = ( cleanUsedIn || '' ).trim();
L.push( ui ? '|usedin=\n' + ui : '|usedin=<!-- List of links to usages on Wikimedia project content pages (optional) -->' );
L.push( '|status=nominated <!-- Change to supported, opposed or discussed as appropriate when adding reviews -->' );
L.push( '|reason=' + ( rs ? rs : '<!-- Reason for nominating (optional) -->' ) );
L.push( '|review=<!-- Itemized list of review comments. -->' );
L.push( '}}' );
return L.join( '\n' );
}
// Create the subpage FIRST (createonly), then append to the \u007b{VICs}} list.
// If the list append fails the subpage is left as an orphan, the error is
// surfaced verbatim so the user can retry that step rather than re-creating.
function submitVic( filePageName, scope, reason, orientation, usedin, api ) {
var name = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
var subpageTitle = VIC_SUBPAGE_PREFIX + name;
var content = buildVicSubpage( scope, reason, orientation, usedin );
return api.postWithToken( 'csrf', {
action: 'edit',
title: subpageTitle,
text: content,
summary: 'Nominating for VI (via [[User:Wilfredor/commons-nominator.js|commons-nominator]])',
createonly: 1,
assert: 'user',
formatversion: 2
} ).catch( function ( err ) {
// Make a retry after a partial run resumable. If step 1 already
// succeeded on an earlier click (orphan subpage) the API now rejects
// with "articleexists". Treat that as "subpage already there" and
// fall through to the list append, appendVicListEntry's own
// idempotency guard completes the nomination instead of dead-ending.
if ( err && err.error && err.error.code === 'articleexists' ) return null;
throw err;
} ).then( function () {
return appendVicListEntry( name, api ).then( function () { return subpageTitle; } );
} );
}
// Append "|<filename>" as the last entry inside \u007b{VICs}}, immediately above
// the "ADD NEW NOMINATIONS ABOVE THIS LINE" anchor. Pins baserevid; idem-
// potent if the entry is already present.
function appendVicListEntry( name, api ) {
return fetchPage( VIC_LIST, api ).then( function ( data ) {
var wt = data.wt;
if ( vicAlreadyNominated( wt, 'File:' + name ) ) {
return { skipped: true };
}
var anchor = '<!--ADD NEW NOMINATIONS ABOVE THIS LINE--->';
var idx = wt.indexOf( anchor );
if ( idx < 0 ) {
throw new Error( 'Subpage was created, but the VIC list insertion anchor was not found,' +
'please add "|' + name + '" to the \u007b{VICs}} list manually.' );
}
var newText = wt.slice( 0, idx ) + '|' + name + '\n' + wt.slice( idx );
return api.postWithToken( 'csrf', {
action: 'edit',
title: VIC_LIST,
text: newText,
summary: 'Adding [\u005b:File:' + name + ']] to the VIC candidate list ' +
'(via [[User:Wilfredor/commons-nominator.js|commons-nominator]])',
baserevid: data.baserevid,
basetimestamp: data.basetimestamp,
nocreate: 1,
assert: 'user',
formatversion: 2
} );
} );
}
function injectStyles() {
var css = [
// Portlet link color hints depending on existing assessments.
// QI = green, VI = blue, QI+VI = purple (synergy hint), already FP = greyed.
'#pt-fpc-nominate.fpc-nominate-qi a { background:#d1fae5 !important; color:#047857 !important; font-weight:bold; }',
'#pt-fpc-nominate.fpc-nominate-vi a { background:#dbeafe !important; color:#1d4ed8 !important; font-weight:bold; }',
'#pt-fpc-nominate.fpc-nominate-qi-vi a { background:linear-gradient(135deg,#d1fae5,#dbeafe) !important; color:#5b21b6 !important; font-weight:bold; }',
'#pt-fpc-nominate.fpc-nominate-already-fp a { background:#f3f4f6 !important; color:#9ca3af !important; text-decoration:line-through; cursor:not-allowed; }',
// Nominate-for-FP dialog (modal triggered by portlet link).
'.fpc-archiver-nominate-overlay { position:fixed; inset:0; background:rgba(15,23,42,0.5); backdrop-filter:blur(2px); -webkit-backdrop-filter:blur(2px); z-index:10000; display:flex; align-items:center; justify-content:center; padding:24px; }',
// Dialog is resizable via the bottom-right corner. min sizes keep
// it usable, max sizes keep it within viewport.
'.fpc-archiver-nominate-dialog { background:#fff; color:#0f172a; border-radius:8px; width:min(1180px, calc(100vw - 48px)); height:min(720px, calc(100vh - 48px)); min-width:640px; min-height:480px; max-width:calc(100vw - 48px); max-height:calc(100vh - 48px); display:flex; flex-direction:column; box-shadow:0 20px 60px rgba(0,0,0,0.35); font-size:14px; line-height:1.4; overflow:hidden; resize:both; }',
// Header: single compact bar with title + close button.
'.fpc-archiver-nominate-dialog .fpc-nominate-header { display:flex; align-items:center; gap:12px; padding:12px 18px; background:#f8fafc; border-bottom:1px solid #e2e8f0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-title { font-size:15px; font-weight:bold; color:#0f172a; flex:1; min-width:0; line-height:1.3; word-break:break-word; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-title i { font-style:normal; color:#1e3a8a; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-close { background:none; border:none; color:#64748b; font-size:22px; line-height:1; padding:2px 8px; cursor:pointer; border-radius:4px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-close:hover { background:#e2e8f0; color:#0f172a; }',
// Body: two columns side by side. Each column scrolls independently
// so a long readiness list doesn\'t push the form off-screen.
'.fpc-archiver-nominate-dialog .fpc-nominate-body { display:flex; gap:0; flex:1; min-height:0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-col-form { flex:0 0 44%; min-width:0; padding:14px 20px 18px; overflow-y:auto; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-col-readiness { flex:1; min-width:0; padding:14px 20px 18px; background:#fafbfc; border-left:1px solid #e2e8f0; overflow-y:auto; }',
'@media (max-width:820px) { .fpc-archiver-nominate-dialog { min-width:unset; width:calc(100vw - 32px); } .fpc-archiver-nominate-dialog .fpc-nominate-body { flex-direction:column; } .fpc-archiver-nominate-dialog .fpc-nominate-col-form, .fpc-archiver-nominate-dialog .fpc-nominate-col-readiness { flex:1 1 auto; } .fpc-archiver-nominate-dialog .fpc-nominate-col-readiness { border-left:none; border-top:1px solid #e2e8f0; } }',
'.fpc-archiver-nominate-dialog .fpc-nominate-section { margin:0 0 14px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-section-title { font-size:12.5px; font-weight:bold; color:#475569; text-transform:uppercase; letter-spacing:0.04em; margin-bottom:6px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-section-hint { font-size:11.5px; color:#94a3b8; font-weight:normal; text-transform:none; letter-spacing:0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-comment { width:100%; box-sizing:border-box; height:80px; padding:8px 10px; font-family:inherit; font-size:13px; border:1px solid #cbd5e1; border-radius:5px; resize:vertical; line-height:1.4; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-comment:focus { outline:none; border-color:#1d4ed8; box-shadow:0 0 0 2px rgba(29,78,216,0.15); }',
// Options row (self-support + notify).
'.fpc-archiver-nominate-dialog .fpc-nominate-options { display:flex; flex-direction:column; gap:6px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-option { display:flex; align-items:center; gap:8px; font-size:13px; color:#334155; padding:6px 10px; background:#f8fafc; border-radius:5px; cursor:pointer; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-option:hover { background:#f1f5f9; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-option input { margin:0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-option code { background:#e0e7ff; color:#3730a3; padding:1px 5px; border-radius:3px; font-size:0.9em; }',
// Footer.
'.fpc-archiver-nominate-dialog .fpc-nominate-footer { padding:10px 18px; border-top:1px solid #e2e8f0; background:#fff; display:flex; align-items:center; gap:12px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-actions { display:flex; gap:8px; margin-left:auto; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-hint { color:#0e7490; font-size:12px; margin:6px 0 0; background:#ecfeff; padding:6px 10px; border-radius:4px; border-left:3px solid #06b6d4; }',
// Live-search autocomplete dropdown under the Gallery input.
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-wrap { position:relative; margin-top:4px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery { width:100%; padding:6px 8px; box-sizing:border-box; font-family:inherit; font-size:inherit; border:1px solid #d1d5db; border-radius:3px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery:focus { outline:none; border-color:#1e40af; box-shadow:0 0 0 2px rgba(30,64,175,0.15); }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-suggest { position:absolute; top:100%; left:0; right:0; background:#fff; border:1px solid #d1d5db; border-top:none; border-radius:0 0 3px 3px; max-height:240px; overflow-y:auto; z-index:10001; box-shadow:0 4px 12px rgba(0,0,0,0.12); }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-suggest-item { padding:6px 10px; cursor:pointer; font-size:0.9em; color:#111827; border-bottom:1px solid #f3f4f6; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-suggest-item:last-child { border-bottom:none; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-suggest-item:hover, .fpc-archiver-nominate-dialog .fpc-nominate-gallery-suggest-item.active { background:#eff6ff; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-suggest-item mark { background:#fde68a; color:#92400e; padding:0 1px; border-radius:2px; }',
// Tree-browser panel under the input.
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree { margin-top:6px; border:1px solid #d1d5db; border-radius:4px; background:#fff; max-height:340px; overflow-y:auto; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-crumbs { padding:6px 10px; background:#f9fafb; border-bottom:1px solid #e5e7eb; font-size:0.88em; color:#374151; position:sticky; top:0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-crumbs a { color:#1e40af; text-decoration:none; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-crumbs a:hover { text-decoration:underline; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-sep { color:#9ca3af; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-pick-here { display:block; width:calc(100% - 16px); margin:8px; padding:6px 10px; background:#ecfdf5; border:1px solid #6ee7b7; color:#065f46; border-radius:3px; cursor:pointer; text-align:left; font-size:0.88em; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-pick-here:hover { background:#d1fae5; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-body { display:flex; gap:0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-col { flex:1; min-width:0; padding:4px 0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-col + .fpc-nominate-gallery-tree-col { border-left:1px solid #e5e7eb; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-colhead { padding:4px 10px; font-size:0.78em; text-transform:uppercase; color:#6b7280; letter-spacing:0.04em; font-weight:bold; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-item { padding:6px 10px; cursor:pointer; font-size:0.9em; color:#111827; line-height:1.3; display:flex; align-items:flex-start; gap:10px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-icon { width:22px; height:22px; object-fit:contain; flex-shrink:0; margin-top:1px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-icon-fallback { width:22px; display:inline-block; text-align:center; flex-shrink:0; margin-top:1px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-text { flex:1; min-width:0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-name { font-weight:500; color:#111827; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-desc { font-size:11px; color:#94a3b8; line-height:1.3; margin-top:1px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-desc:empty { display:none; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-item:hover { background:#eff6ff; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-section { color:#5b21b6; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-loading, .fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-empty { padding:14px; color:#6b7280; font-size:0.88em; text-align:center; }',
'.fpc-archiver-nominate-dialog button { padding:8px 16px; border:1px solid #cbd5e1; background:#fff; border-radius:5px; cursor:pointer; font-size:13px; font-weight:500; color:#334155; transition:background 0.12s, border-color 0.12s, transform 0.06s; }',
'.fpc-archiver-nominate-dialog button:hover { background:#f1f5f9; border-color:#94a3b8; }',
'.fpc-archiver-nominate-dialog button:active { transform:translateY(1px); }',
'.fpc-archiver-nominate-dialog .fpc-nominate-submit { background:linear-gradient(180deg,#2563eb,#1d4ed8); color:#fff; border-color:#1e3a8a; padding:8px 20px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-submit:hover { background:linear-gradient(180deg,#1d4ed8,#1e3a8a); border-color:#1e3a8a; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-submit:disabled { background:#94a3b8; cursor:default; border-color:#94a3b8; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-status { font-size:12.5px; color:#475569; flex:1; min-width:0; }',
// Samples-of-this-category panel (right column, top).
'.fpc-archiver-nominate-dialog .fpc-nominate-samples-section { margin:0 0 18px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-samples-body { font-size:12.5px; color:#475569; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-samples-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(110px, 1fr)); gap:6px; margin-top:4px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-samples-thumb { display:block; aspect-ratio:1/1; background:#e2e8f0; border-radius:4px; overflow:hidden; border:1px solid #e2e8f0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-samples-thumb img { width:100%; height:100%; object-fit:cover; display:block; transition:transform 0.18s; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-samples-thumb:hover img { transform:scale(1.06); }',
'.fpc-archiver-nominate-dialog .fpc-nominate-samples-meta { margin-top:8px; font-size:11px; color:#94a3b8; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-samples-meta a { color:#1d4ed8; text-decoration:none; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-samples-empty { font-size:12px; color:#94a3b8; padding:8px 0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-samples-empty a { color:#1d4ed8; text-decoration:none; }',
// Pre-submit readiness panel (verdict pill + N key signals).
'.fpc-archiver-nominate-dialog .fpc-nominate-readiness-section { margin:0; padding:0; padding-top:14px; border-top:1px solid #e2e8f0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-readiness-pill { display:inline-block; padding:3px 10px; border-radius:12px; font-weight:bold; font-size:0.88em; }',
'.fpc-archiver-nominate-dialog .fpc-ready-ok { background:#d1fae5; color:#047857; border:1px solid #6ee7b7; }',
'.fpc-archiver-nominate-dialog .fpc-ready-borderline { background:#fef3c7; color:#92400e; border:1px solid #fbbf24; }',
'.fpc-archiver-nominate-dialog .fpc-ready-risky { background:#fee2e2; color:#991b1b; border:1px solid #f87171; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-readiness-lines { margin:10px 0 0; padding:0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-readiness-line { display:flex; gap:10px; padding:10px 12px; background:#fff; border:1px solid #e2e8f0; border-radius:5px; margin-bottom:6px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-readiness-line-critical { border-color:#fca5a5; background:#fef2f2; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-readiness-badge { width:20px; height:20px; flex-shrink:0; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:bold; font-size:12px; line-height:1; }',
'.fpc-archiver-nominate-dialog .fpc-readiness-badge-warn { background:#fef3c7; color:#92400e; }',
'.fpc-archiver-nominate-dialog .fpc-readiness-badge-crit { background:#fee2e2; color:#991b1b; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-readiness-line-body { flex:1; min-width:0; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-readiness-line-name { font-weight:bold; font-size:13px; color:#0f172a; margin-bottom:3px; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-readiness-line-note { font-size:12px; line-height:1.45; color:#475569; }',
'.fpc-archiver-nominate-dialog .fpc-nominate-readiness-more { margin-top:6px; font-size:11.5px; color:#94a3b8; font-style:italic; text-align:center; }',
// Rule-11 banner shown on FPC subpage creation when user is over limit.
'.fpc-archiver-2nom-banner { background:#fef3c7; border:2px solid #d97706; color:#7c2d12; padding:14px 18px; margin:12px 0 18px; border-radius:6px; line-height:1.5; }',
'.fpc-archiver-2nom-banner .fpc-archiver-2nom-head { font-size:1.05em; font-weight:bold; margin-bottom:6px; }',
'.fpc-archiver-2nom-banner ul { margin:6px 0 8px 24px; }',
'.fpc-archiver-2nom-banner .fpc-archiver-2nom-override { margin-left:8px; padding:4px 10px; background:#fff; border:1px solid #92400e; color:#7c2d12; border-radius:3px; cursor:pointer; }',
'.fpc-archiver-2nom-banner .fpc-archiver-2nom-override:disabled { background:#f3f4f6; cursor:default; }',
// commons-nominator: QIC/VIC portlet hints + compact dialog.
'#pt-qic-nominate.cn-already a, #pt-vic-nominate.cn-already a, #pt-qic-nominate.cn-notown a { color:#9ca3af !important; }',
'.cn-disabled a { cursor:default !important; opacity:0.6; text-decoration:none !important; }',
'.cn-fpc-rename-btn { margin-left:10px; padding:3px 8px; font-size:12px; line-height:1.2; border:1px solid #a2a9b1; border-radius:3px; background:#f8f9fa; color:#202122; cursor:pointer; vertical-align:middle; }',
'.cn-fpc-rename-btn:hover { background:#fff; border-color:#72777d; }',
'.cn-rename-form { display:grid; gap:10px; min-width:min(560px, 82vw); }',
'.cn-rename-form label { display:grid; gap:4px; font-size:12px; font-weight:700; color:#334155; }',
'.cn-rename-form input { box-sizing:border-box; width:100%; padding:7px 9px; border:1px solid #cbd5e1; border-radius:5px; font-size:13px; font-weight:400; color:#0f172a; background:#fff; }',
'.cn-rename-form input[readonly] { background:#f8fafc; color:#475569; }',
'.cn-rename-plan { margin-top:2px; padding:10px 12px; border:1px solid #e2e8f0; border-radius:5px; background:#f8fafc; font-size:12.5px; color:#334155; }',
'.cn-rename-plan ul { margin:6px 0 0 18px; padding:0; }',
'.cn-cat-actions { margin:3px 0 2px; text-align:center; line-height:1.4; }',
'.cn-cat-link { display:inline-block; margin:1px 3px; padding:1px 7px; font-size:11px; font-weight:600; border-radius:9px; background:#1f2937; color:#fff !important; text-decoration:none !important; cursor:pointer; }',
'.cn-cat-link:hover { background:#374151; }',
// QI nomination. A plain file gets a quiet grey pill; a featured
// picture that is still not a quality image gets a filled green one,
// because that is the case worth acting on and it has to read as
// different from a beat away rather than on hover.
'.cn-cat-link.cn-cat-qi { background:#6b7280; }',
'.cn-cat-link.cn-cat-qi:hover { background:#4b5563; }',
'.cn-cat-link.cn-cat-qi-fp { background:#15803d; box-shadow:0 0 0 2px #bbf7d0; }',
'.cn-cat-link.cn-cat-qi-fp:hover { background:#166534; }',
// The daily cap is spent. Still visible, so the picture is not
// silently forgotten, but plainly not pressable.
'.cn-cat-link.cn-cat-qi-off, .cn-cat-link.cn-cat-qi-off:hover { background:#d1d5db; color:#6b7280 !important; cursor:default; box-shadow:none; }',
'.cn-cat-link.cn-cat-enfp { background:#b45309; }',
'.cn-cat-link.cn-cat-enfp:hover { background:#92400e; }',
'.cn-cat-flag { display:inline-block; margin:1px 3px; padding:1px 7px; font-size:11px; font-weight:600; border-radius:9px; background:#e2e8f0; color:#475569; cursor:default; }',
'.cn-cat-link.cn-cat-find { background:#0f766e; }',
'.cn-cat-link.cn-cat-find:hover { background:#115e59; }',
'.cn-sugg-snip { display:flex; gap:6px; margin:0 0 10px; }',
'.cn-sugg-snip input { flex:1; min-width:0; padding:6px 8px; font-size:12px; border:1px solid #cbd5e1; border-radius:5px; background:#f8fafc; color:#334155; }',
'.cn-sugg-copy { padding:6px 12px; border:0; border-radius:5px; background:#1d4ed8; color:#fff; font-weight:600; cursor:pointer; }',
'.cn-sugg-copy:hover { background:#1e40af; }',
'.cn-sugg-row { padding:8px 0; border-top:1px solid #eef2f7; }',
'.cn-sugg-row.cn-sugg-good { background:#f0fdf4; margin:0 -8px; padding:8px; border-top:0; border-radius:6px; }',
'.cn-sugg-head { display:flex; align-items:center; flex-wrap:wrap; gap:6px; }',
'.cn-sugg-title { color:#1d4ed8; font-weight:600; font-size:13px; }',
'.cn-sugg-src { font-size:10.5px; font-weight:600; padding:1px 6px; border-radius:8px; background:#e2e8f0; color:#475569; }',
'.cn-sugg-src-depicts { background:#ede9fe; color:#6d28d9; }',
'.cn-sugg-badge { font-size:10.5px; font-weight:700; padding:1px 6px; border-radius:8px; background:#16a34a; color:#fff; }',
'.cn-sugg-badge-muted { background:#cbd5e1 !important; color:#475569 !important; }',
'.cn-sugg-note { font-size:12px; color:#64748b; line-height:1.4; margin:3px 0 2px; }',
'.cn-sugg-link { font-size:12px; color:#0f766e; font-weight:600; }',
'.cn-sugg-toggle { display:inline-block; margin:10px 0 2px; font-size:12px; font-weight:600; color:#475569; }',
'.cn-sugg-hidden .cn-sugg-row { opacity:0.72; }',
'.cn-enfp-cat, .cn-enfp-creator { width:100%; box-sizing:border-box; padding:6px 8px; font-size:13px; border:1px solid #cbd5e1; border-radius:5px; }',
'.cn-quota.cn-quota-warn { background:#fffbeb; border-color:#fcd34d; color:#78350f; }',
'.cn-enfp-grid { display:flex; gap:14px; align-items:flex-start; }',
'.cn-enfp-grid .cn-enfp-fields { flex:1; min-width:0; }',
'.cn-enfp-thumb { flex:0 0 168px; width:168px; text-align:center; }',
'.cn-enfp-thumb img { max-width:100%; border-radius:6px; border:1px solid #e2e8f0; background:#f8fafc; display:block; }',
'.cn-enfp-thumb-cap { margin-top:4px; font-size:11px; color:#64748b; line-height:1.35; word-break:break-word; }',
'.cn-enfp-preview { margin:0; padding:8px 10px; background:#0f172a; color:#e2e8f0; border-radius:5px; font-size:11.5px; line-height:1.5; white-space:pre-wrap; word-break:break-word; max-height:190px; overflow:auto; }',
'@media (max-width:520px){ .cn-enfp-grid { display:block; } .cn-enfp-thumb { width:auto; margin-bottom:10px; } }',
'.cn-wd-row { padding:6px 0; border-top:1px solid #eef2f7; font-size:13px; }',
'.cn-wd-row:first-child { border-top:0; }',
'.cn-wd-row a { color:#1d4ed8; }',
'.cn-wd-has { color:#64748b; font-style:italic; }',
'.cn-wd-set { margin-left:6px; padding:3px 10px; background:#0d9488; color:#fff; border:0; border-radius:4px; font-weight:600; cursor:pointer; }',
'.cn-wd-set:hover { background:#0f766e; }',
'.cn-wd-set:disabled { opacity:0.6; cursor:default; }',
'.cn-wd-done { color:#0d9488; font-weight:700; }',
'.fpc-archiver-nominate-dialog.cn-compact-dialog { width:min(560px, calc(100vw - 48px)); height:auto; max-height:min(640px, calc(100vh - 48px)); min-width:unset; min-height:unset; resize:none; }',
'.fpc-archiver-nominate-dialog.cn-compact-dialog .cn-compact-body { display:block; padding:16px 20px; overflow-y:auto; }',
'.fpc-archiver-nominate-dialog .cn-compact-body textarea { width:100%; box-sizing:border-box; min-height:64px; padding:8px 10px; font-family:inherit; font-size:13px; border:1px solid #cbd5e1; border-radius:5px; resize:vertical; line-height:1.4; }',
'.fpc-archiver-nominate-dialog .cn-compact-body textarea:focus, .fpc-archiver-nominate-dialog .cn-compact-body select:focus { outline:none; border-color:#1d4ed8; box-shadow:0 0 0 2px rgba(29,78,216,0.15); }',
'.fpc-archiver-nominate-dialog .cn-compact-body select { padding:6px 8px; font-family:inherit; font-size:13px; border:1px solid #cbd5e1; border-radius:5px; }',
'.fpc-archiver-nominate-dialog .cn-hint { color:#475569; font-size:12px; margin-top:6px; line-height:1.45; }',
'.fpc-archiver-nominate-dialog .cn-hint a { color:#1d4ed8; }',
'.fpc-archiver-nominate-dialog .cn-quota { font-size:13px; color:#334155; background:#f8fafc; border:1px solid #e2e8f0; border-radius:5px; padding:8px 10px; }',
''
].join( '\n' );
var s = document.createElement( 'style' );
s.textContent = css;
document.head.appendChild( s );
}
}() );