MediaWiki:Common.js: Difference between revisions
MediaWiki interface page
More actions
+ comics.js |
No edit summary |
||
| (35 intermediate revisions by the same user not shown) | |||
| Line 250: | Line 250: | ||
/* End of includePage */ | /* End of includePage */ | ||
/* | /** | ||
* BattlestarWiki — Main Page JavaScript | |||
* Append to MediaWiki:Common.js | |||
* | |||
* Handles: | |||
* 1. Hero slideshow auto-rotation + article image fetching | |||
* 2. Featured Article of the Day — daily deterministic pick, | |||
* excluding Category:Stub_Pages, with extract + thumbnail | |||
* 3. Media wiki file count (from media.battlestarwiki.org) | |||
* 4. Recent changes tab switching (EN / DE / Media wikis) | |||
* 5. Red-link card filtering | |||
* | |||
* All logic is scoped to .bsw-main-page; safe to load globally. | |||
*/ | |||
( function () { | |||
'use strict'; | |||
// | var API = 'https://en.battlestarwiki.org/w/api.php'; | ||
var MEDIA_API = 'https://media.battlestarwiki.org/w/api.php'; | |||
var DE_API = 'https://de.battlestarwiki.org/w/api.php'; | |||
/* | /* ── Shared utilities ─────────────────────────────────────────── */ | ||
function dailySeed( dateStr ) { | |||
var d; | |||
if ( dateStr ) { | |||
var p = dateStr.split( '-' ); | |||
d = Date.UTC( +p[0], +p[1] - 1, +p[2] ); | |||
} else { | |||
var now = new Date(); | |||
d = Date.UTC( now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() ); | |||
} | |||
return Math.floor( d / 86400000 ); | |||
} | |||
function esc( s ) { | |||
return String( s || '' ) | |||
.replace( /&/g, '&' ) | |||
.replace( /</g, '<' ) | |||
.replace( />/g, '>' ) | |||
.replace( /"/g, '"' ); | |||
} | |||
function apiGetFrom( baseUrl, params ) { | |||
params.format = 'json'; | |||
params.origin = '*'; | |||
var qs = Object.keys( params ) | |||
.map( function ( k ) { return encodeURIComponent( k ) + '=' + encodeURIComponent( params[k] ); } ) | |||
.join( '&' ); | |||
return fetch( baseUrl + '?' + qs, { headers: { Accept: 'application/json' } } ) | |||
.then( function ( r ) { | |||
if ( !r.ok ) throw new Error( 'API HTTP ' + r.status ); | |||
return r.json(); | |||
} ); | |||
} | |||
function apiGet( params ) { | |||
return apiGetFrom( API, params ); | |||
} | |||
/* ════════════════════════════════════════════════════════════════ | |||
1. HERO SLIDESHOW | |||
Dynamically populated from the wiki's most-viewed articles. | |||
Uses a daily seed so all visitors see the same 3 slides each day. | |||
Falls back to the static Lua-rendered slides if the API fails. | |||
════════════════════════════════════════════════════════════════ */ | |||
/* Series classification for badge color + label */ | |||
var SERIES_CATS = [ | |||
{ pat: /\bRDM\b|\bTRS\b|Re-imagined/i, label: 'Re-imagined Series', color: 'var(--color-primary)' }, | |||
{ pat: /\bTOS\b|Original Series/i, label: 'Original Series', color: '#ef9f27' }, | |||
{ pat: /Galactica 1980|1980/i, label: 'Galactica 1980', color: '#97c459' }, | |||
{ pat: /Caprica/i, label: 'Caprica', color: '#d4537e' }, | |||
{ pat: /Blood.*Chrome|BAC/i, label: 'Blood & Chrome', color: '#e24b4a' } | |||
]; | |||
function classifyArticle( title, cats ) { | |||
/* Check category names first */ | |||
if ( cats ) { | |||
for ( var i = 0; i < SERIES_CATS.length; i++ ) { | |||
for ( var j = 0; j < cats.length; j++ ) { | |||
if ( SERIES_CATS[i].pat.test( cats[j].title ) ) return SERIES_CATS[i]; | |||
} | |||
} | |||
} | |||
/* Fall back to title matching */ | |||
for ( var k = 0; k < SERIES_CATS.length; k++ ) { | |||
if ( SERIES_CATS[k].pat.test( title ) ) return SERIES_CATS[k]; | |||
} | |||
return { label: 'Battlestar Wiki', color: 'var(--color-primary)' }; | |||
} | |||
function buildSlideHTML( article, series, imgUrl ) { | |||
var title = article.title; | |||
var url = '/wiki/' + encodeURIComponent( title.replace( / /g, '_' ) ); | |||
var desc = ( article.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 160 ); | |||
if ( desc.length === 160 ) desc = desc.replace( /\s\S+$/, '' ) + '…'; | |||
var badgeStyle = series.color ? ' style="background:' + series.color + '"' : ''; | |||
return '<div class="bsw-slide bsw-active" data-article="' + esc( title ) + '">' + | |||
'<div class="bsw-slide-bg"></div>' + | |||
'<div class="bsw-slide-overlay"></div>' + | |||
'<div class="bsw-slide-content">' + | |||
'<div class="bsw-slide-badge">' + | |||
'<span class="bsw-slide-badge-dot"' + badgeStyle + '></span>' + | |||
'<span>' + esc( series.label ) + '</span>' + | |||
'</div>' + | |||
'<div class="bsw-slide-title"><a href="' + esc( url ) + '">' + esc( title ) + '</a></div>' + | |||
'<div class="bsw-slide-desc">' + esc( desc ) + '</div>' + | |||
'</div>' + | |||
'</div>'; | |||
} | |||
function buildDotsAndNav( count ) { | |||
var dots = '<div class="bsw-hero-dots">'; | |||
for ( var i = 0; i < count; i++ ) { | |||
var cls = 'bsw-hero-dot' + ( i === 0 ? ' bsw-active' : '' ); | |||
dots += '<span class="' + cls + '" role="button" tabindex="0" onclick="bswGoSlide(' + i + ')"></span>'; | |||
} | |||
dots += '</div>'; | |||
var nav = '<div class="bsw-hero-nav">' + | |||
'<span class="bsw-hero-btn" role="button" tabindex="0" id="bsw-hero-prev" onclick="bswPrevSlide()">‹</span>' + | |||
'<span class="bsw-hero-btn" role="button" tabindex="0" id="bsw-hero-next" onclick="bswNextSlide()">›</span>' + | |||
'</div>'; | |||
return dots + nav; | |||
} | |||
function initHeroSlideshow( hero, slides ) { | |||
var dots = hero.querySelectorAll( '.bsw-hero-dot' ); | |||
var current = 0; | |||
var total = slides.length; | |||
var timer; | |||
function goTo( n ) { | |||
slides[ current ].classList.remove( 'bsw-active' ); | |||
dots[ current ] && dots[ current ].classList.remove( 'bsw-active' ); | |||
current = ( n + total ) % total; | |||
slides[ current ].classList.add( 'bsw-active' ); | |||
dots[ current ] && dots[ current ].classList.add( 'bsw-active' ); | |||
resetTimer(); | |||
} | |||
function resetTimer() { | |||
clearInterval( timer ); | |||
timer = setInterval( function () { goTo( current + 1 ); }, 6000 ); | |||
} | |||
window.bswGoSlide = function ( n ) { goTo( n ); }; | |||
window.bswPrevSlide = function () { goTo( current - 1 ); }; | |||
window.bswNextSlide = function () { goTo( current + 1 ); }; | |||
dots.forEach( function ( dot, i ) { | |||
dot.addEventListener( 'click', function () { goTo( i ); } ); | |||
} ); | |||
var prevBtn = document.getElementById( 'bsw-hero-prev' ); | |||
var nextBtn = document.getElementById( 'bsw-hero-next' ); | |||
if ( prevBtn ) prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } ); | |||
if ( nextBtn ) nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } ); | |||
hero.addEventListener( 'mouseenter', function () { clearInterval( timer ); } ); | |||
hero.addEventListener( 'mouseleave', resetTimer ); | |||
resetTimer(); | |||
} | |||
function fetchSlideImages( hero, slides ) { | |||
var titles = Array.from( slides ).map( function ( s ) { return s.dataset.article; } ).filter( Boolean ); | |||
if ( !titles.length ) return; | |||
apiGet( { | |||
action: 'query', | |||
titles: titles.join( '|' ), | |||
prop: 'pageimages', | |||
piprop: 'thumbnail|original', | |||
pithumbsize: '1200', | |||
redirects: '1' | |||
} ).then( function ( data ) { | |||
var pages = data.query.pages; | |||
var imageMap = {}; | |||
var normalized = {}; | |||
if ( data.query.normalized ) { | |||
data.query.normalized.forEach( function ( n ) { normalized[ n.to ] = n.from; } ); | |||
} | |||
var redirects = {}; | |||
if ( data.query.redirects ) { | |||
data.query.redirects.forEach( function ( r ) { redirects[ r.to ] = r.from; } ); | |||
} | |||
Object.values( pages ).forEach( function ( page ) { | |||
if ( !page.title ) return; | |||
var img = ( page.original && page.original.source ) || | |||
( page.thumbnail && page.thumbnail.source ) || ''; | |||
if ( !img ) return; | |||
imageMap[ page.title ] = img; | |||
if ( redirects[ page.title ] ) imageMap[ redirects[ page.title ] ] = img; | |||
if ( normalized[ page.title ] ) imageMap[ normalized[ page.title ] ] = img; | |||
} ); | |||
slides.forEach( function ( slide ) { | |||
var imgUrl = imageMap[ slide.dataset.article ]; | |||
if ( !imgUrl ) return; | |||
var bg = slide.querySelector( '.bsw-slide-bg' ); | |||
if ( !bg ) return; | |||
var bgImg = document.createElement( 'img' ); | |||
bgImg.src = imgUrl; | |||
bgImg.alt = ''; | |||
bgImg.className = 'bsw-slide-bg-blur'; | |||
bgImg.setAttribute( 'aria-hidden', 'true' ); | |||
var fgImg = document.createElement( 'img' ); | |||
fgImg.src = imgUrl; | |||
fgImg.alt = ''; | |||
fgImg.className = 'bsw-slide-bg-img'; | |||
bg.appendChild( bgImg ); | |||
bg.appendChild( fgImg ); | |||
} ); | |||
} ).catch( function () {} ); | |||
} | |||
function initHero() { | |||
var hero = document.querySelector( '.bsw-hero' ); | |||
if ( !hero ) return; | |||
var seed = dailySeed(); | |||
var SLIDE_COUNT = 3; | |||
var FETCH_BATCH = 20; /* fetch more than needed so we can filter for ones with images */ | |||
/* Fetch random mainspace articles, then filter to those with images */ | |||
apiGet( { | |||
action: 'query', | |||
list: 'random', | |||
rnnamespace: '0', | |||
rnlimit: String( FETCH_BATCH ) | |||
} ).then( function ( data ) { | |||
var items = ( data.query.random || [] ).filter( function ( p ) { | |||
return p.title !== 'Main Page' && p.title !== 'Main Page/New'; | |||
} ); | |||
if ( !items.length ) throw new Error( 'No articles' ); | |||
/* Fetch extracts + images + categories for all candidates in one call */ | |||
return apiGet( { | |||
action: 'query', | |||
titles: items.map( function ( p ) { return p.title; } ).join( '|' ), | |||
prop: 'extracts|pageimages|categories', | |||
exintro: '1', | |||
exchars: '200', | |||
exsectionformat: 'plain', | |||
piprop: 'thumbnail|original', | |||
pithumbsize: '1200', | |||
cllimit: '20', | |||
clcategories: 'Category:Disambiguation|Category:Stub_Pages', | |||
redirects: '1' | |||
} ); | |||
} ).then( function ( data ) { | |||
var pages = Object.values( data.query.pages || {} ); | |||
/* Keep only articles that have an image and aren't disambiguation pages */ | |||
var withImages = pages.filter( function ( p ) { | |||
if ( !p.thumbnail && !p.original ) return false; | |||
var cats = ( p.categories || [] ).map( function ( c ) { return c.title; } ); | |||
return !cats.some( function ( c ) { return c === 'Category:Disambiguation' || /[Ss]tub/.test( c ); } ); | |||
} ); | |||
/* Fall back to all non-disambiguation pages if not enough have images */ | |||
var pool = withImages.length >= SLIDE_COUNT ? withImages : pages.filter( function ( p ) { | |||
var cats = ( p.categories || [] ).map( function ( c ) { return c.title; } ); | |||
return !cats.some( function ( c ) { return c === 'Category:Disambiguation' || /[Ss]tub/.test( c ); } ); | |||
} ); | |||
if ( pool.length < SLIDE_COUNT ) throw new Error( 'Not enough articles' ); | |||
/* Pick SLIDE_COUNT articles using daily seed with spacing */ | |||
var picks = []; | |||
for ( var i = 0; i < SLIDE_COUNT; i++ ) { | |||
picks.push( pool[ ( seed + i * 7 ) % pool.length ] ); | |||
} | |||
/* Build slide HTML */ | |||
var slideHTML = picks.map( function ( article, i ) { | |||
var cats = article.categories || []; | |||
var series = classifyArticle( article.title, cats ); | |||
var html = buildSlideHTML( article, series, '' ); | |||
return i === 0 ? html : html.replace( 'bsw-slide bsw-active', 'bsw-slide' ); | |||
} ).join( '' ); | |||
hero.innerHTML = slideHTML + buildDotsAndNav( picks.length ); | |||
var newSlides = hero.querySelectorAll( '.bsw-slide' ); | |||
initHeroSlideshow( hero, newSlides ); | |||
/* Inject images directly from the data we already fetched */ | |||
picks.forEach( function ( article, i ) { | |||
var imgUrl = ( article.original && article.original.source ) || | |||
( article.thumbnail && article.thumbnail.source ) || ''; | |||
if ( !imgUrl ) return; | |||
var bg = newSlides[ i ] && newSlides[ i ].querySelector( '.bsw-slide-bg' ); | |||
if ( !bg ) return; | |||
var bgImg = document.createElement( 'img' ); | |||
bgImg.src = imgUrl; | |||
bgImg.alt = ''; | |||
bgImg.className = 'bsw-slide-bg-blur'; | |||
bgImg.setAttribute( 'aria-hidden', 'true' ); | |||
var fgImg = document.createElement( 'img' ); | |||
fgImg.src = imgUrl; | |||
fgImg.alt = ''; | |||
fgImg.className = 'bsw-slide-bg-img'; | |||
bg.appendChild( bgImg ); | |||
bg.appendChild( fgImg ); | |||
} ); | |||
} ).catch( function () { | |||
/* Fallback: use static Lua-rendered slides */ | |||
var staticSlides = hero.querySelectorAll( '.bsw-slide' ); | |||
if ( staticSlides.length ) { | |||
staticSlides[0].classList.add( 'bsw-active' ); | |||
initHeroSlideshow( hero, staticSlides ); | |||
fetchSlideImages( hero, staticSlides ); | |||
} | |||
} ); | |||
} | |||
/* ════════════════════════════════════════════════════════════════ | |||
2. FEATURED ARTICLE OF THE DAY | |||
Daily deterministic pick from all mainspace articles, | |||
excluding Category:Stub_Pages. | |||
════════════════════════════════════════════════════════════════ */ | |||
function initFeatured() { | |||
var container = document.getElementById( 'bsw-featured-inner' ); | |||
if ( !container ) return; | |||
var seed = dailySeed(); | |||
/* We fetch batches of 20 random articles, check for stubs, | |||
and pick the first clean one. Two batches covers ~99.99% | |||
of cases given only ~9% of articles are stubs. */ | |||
fetchCandidateBatch( seed ) | |||
.then( function ( pages ) { | |||
var clean = pages.filter( function ( p ) { | |||
/* If categories array is present and contains Stub_Pages, | |||
exclude. Absence of categories key means not a stub. */ | |||
if ( !p.categories ) return true; | |||
return !p.categories.some( function ( c ) { | |||
return c.title === 'Category:Stub_Pages'; | |||
} ); | |||
} ); | |||
if ( !clean.length ) { | |||
/* Extremely unlikely — fall back to second batch */ | |||
return fetchCandidateBatch( seed + 1 ); | |||
} | |||
return clean; | |||
} ) | |||
.then( function ( pages ) { | |||
/* Pick deterministically from the clean pool */ | |||
var pick = pages[ seed % pages.length ]; | |||
renderFeatured( container, pick ); | |||
} ) | |||
.catch( function ( e ) { | |||
container.innerHTML = | |||
'<div class="bsw-error">Could not load featured article. ' + esc( e.message ) + '</div>'; | |||
} ); | |||
} | |||
/** | |||
* Fetch 20 random mainspace articles with extract, thumbnail, | |||
* and stub-category membership check in a single API call. | |||
* Uses rnstart derived from seed for determinism. | |||
*/ | |||
function fetchCandidateBatch( seed ) { | |||
/* Step 1: get 20 random page IDs */ | |||
return apiGet( { | |||
action: 'query', | |||
list: 'random', | |||
rnnamespace: '0', | |||
rnlimit: '20' | |||
} ).then( function ( data ) { | |||
var ids = data.query.random.map( function ( p ) { return p.id; } ).join( '|' ); | |||
/* Step 2: batch fetch extract + thumbnail + stub check */ | |||
return apiGet( { | |||
action: 'query', | |||
pageids: ids, | |||
prop: 'extracts|pageimages|categories', | |||
exintro: '1', | |||
exchars: '600', | |||
exsectionformat: 'plain', | |||
piprop: 'thumbnail', | |||
pithumbsize: '320', | |||
clcategories: 'Category:Stub_Pages', | |||
cllimit: '1' | |||
} ); | |||
} ).then( function ( data ) { | |||
return Object.values( data.query.pages ); | |||
} ); | |||
} | |||
function renderFeatured( container, page ) { | |||
var title = page.title || ''; | |||
var url = 'https://en.battlestarwiki.org/wiki/' + encodeURIComponent( title.replace( / /g, '_' ) ); | |||
var extract = page.extract || ''; | |||
var thumb = page.thumbnail ? page.thumbnail.source : ''; | |||
var thumbHtml; | |||
if ( thumb ) { | |||
thumbHtml = '<img class="bsw-fa-thumb" src="' + esc( thumb ) + '" alt="' + esc( title ) + '">'; | |||
} else { | |||
thumbHtml = '<img class="bsw-fa-thumb" src="https://en.battlestarwiki.org/resources/BSGWIKILOGO.png" alt="Battlestar Wiki">'; | |||
} | |||
container.innerHTML = | |||
thumbHtml + | |||
'<div>' + | |||
'<div class="bsw-fa-title">' + | |||
'<a href="' + esc( url ) + '">' + esc( title ) + '</a>' + | |||
'</div>' + | |||
'<div class="bsw-fa-extract">' + extract + '</div>' + | |||
'<div style="margin-top:0.375rem">' + | |||
'<a href="' + esc( url ) + '" style="font-size:0.75rem;color:var(--color-link)">Read more \u2192</a>' + | |||
'</div>' + | |||
'</div>'; | |||
/* Update the "Read more" span in the card header to link to the article */ | |||
var hdrLink = document.getElementById( 'bsw-featured-link' ); | |||
if ( hdrLink ) { | |||
/* Replace the span with a proper anchor now that we have a URL */ | |||
var a = document.createElement( 'a' ); | |||
a.href = url; | |||
a.textContent = 'Read more \u2192'; | |||
hdrLink.parentNode.replaceChild( a, hdrLink ); | |||
} | |||
} | |||
/** | |||
* Fetch file count from media.battlestarwiki.org and populate | |||
* the #bsw-stat-files span in the statistics card. | |||
*/ | |||
function initMediaFileCount() { | |||
var el = document.getElementById( 'bsw-stat-files' ); | |||
if ( !el ) return; | |||
apiGetFrom( MEDIA_API, { | |||
action: 'query', | |||
meta: 'siteinfo', | |||
siprop: 'statistics' | |||
} ).then( function ( data ) { | |||
var files = data.query.statistics.images || 0; | |||
el.textContent = files.toLocaleString(); | |||
} ).catch( function () { | |||
el.textContent = '—'; | |||
} ); | |||
} | |||
/** | |||
* Recent changes tab switching. | |||
* Fetches live recent changes from the selected wiki's API | |||
* and replaces the #bsw-rc-content div content. | |||
*/ | |||
var rcCurrentWiki = 'all'; | |||
var rcCache = {}; | |||
function loadRecentChanges( wiki ) { | |||
var wrap = document.getElementById( 'bsw-rc-content' ); | |||
if ( !wrap ) return; | |||
if ( rcCache[ wiki ] ) { | |||
wrap.innerHTML = rcCache[ wiki ]; | |||
return; | |||
} | |||
/* 'all' shows the cached Special:RecentChanges transclude */ | |||
if ( wiki === 'all' ) { | |||
if ( rcCache.all ) { | |||
wrap.innerHTML = rcCache.all; | |||
} | |||
return; | |||
} | |||
wrap.innerHTML = '<p style="color:var(--color-base--subtle);font-size:0.8125rem;padding:0.5rem 0">Loading\u2026</p>'; | |||
var apiMap = { en: API, de: DE_API, media: MEDIA_API }; | |||
var base = apiMap[ wiki ]; | |||
if ( !base ) return; | |||
apiGetFrom( base, { | |||
action: 'query', | |||
list: 'recentchanges', | |||
rcprop: 'title|timestamp|user|sizes', | |||
rcnamespace: '0', | |||
rclimit: '8', | |||
rctype: 'edit|new' | |||
} ).then( function ( data ) { | |||
var changes = data.query.recentchanges || []; | |||
if ( !changes.length ) { | |||
wrap.innerHTML = '<p style="color:var(--color-base--subtle);font-size:0.8125rem;padding:0.5rem 0">No recent changes.</p>'; | |||
return; | |||
} | |||
var baseUrl = { | |||
en: 'https://en.battlestarwiki.org/wiki/', | |||
de: 'https://de.battlestarwiki.org/wiki/', | |||
media: 'https://media.battlestarwiki.org/wiki/' | |||
}[ wiki ]; | |||
var html = '<ul class="bsw-recent">' + changes.map( function ( rc ) { | |||
var title = rc.title; | |||
var url = baseUrl + encodeURIComponent( title.replace( / /g, '_' ) ); | |||
var ts = new Date( rc.timestamp ); | |||
var ago = timeAgo( ts ); | |||
return '<li class="ri">' + | |||
'<a href="' + esc( url ) + '" target="_blank">' + esc( title ) + '</a>' + | |||
'<span class="bsw-recent-time">' + esc( ago ) + '</span>' + | |||
'</li>'; | |||
} ).join( '' ) + '</ul>'; | |||
rcCache[ wiki ] = html; | |||
wrap.innerHTML = html; | |||
} ).catch( function () { | |||
wrap.innerHTML = '<p style="color:var(--color-base--subtle);font-size:0.8125rem;padding:0.5rem 0">Could not load recent changes.</p>'; | |||
} ); | |||
} | |||
function timeAgo( date ) { | |||
var diff = Math.floor( ( Date.now() - date.getTime() ) / 1000 ); | |||
if ( diff < 60 ) return diff + 's ago'; | |||
if ( diff < 3600 ) return Math.floor( diff / 60 ) + ' min ago'; | |||
if ( diff < 86400 ) return Math.floor( diff / 3600 ) + ' hr ago'; | |||
return Math.floor( diff / 86400 ) + ' days ago'; | |||
} | |||
/* ── Entry point ──────────────────────────────────────────────── */ | |||
/** | |||
* Hide any .bsw-card whose .bsw-card-body contains nothing but | |||
* a red link (missing page transclusion). This catches subpages | |||
* like BattlestarWiki:Did_you_know, OTD/Month_Day etc. that | |||
* haven't been created yet. | |||
*/ | |||
function filterRedlinkCards() { | |||
document.querySelectorAll( '.bsw-main-page .bsw-card' ).forEach( function ( card ) { | |||
var body = card.querySelector( '.bsw-card-body' ); | |||
if ( !body ) return; | |||
/* Remove non-content elements before checking — date labels, | |||
loading spinners etc. shouldn't count as "content" */ | |||
var clone = body.cloneNode( true ); | |||
/* Remove known non-content wrappers */ | |||
clone.querySelectorAll( '.bsw-otd-date, .bsw-spinner, .bsw-loading' ) | |||
.forEach( function ( el ) { el.remove(); } ); | |||
var text = clone.textContent.trim(); | |||
var links = clone.querySelectorAll( 'a' ); | |||
var redLinks = clone.querySelectorAll( 'a.new' ); | |||
/* Hide if nothing left after stripping non-content */ | |||
if ( text === '' ) { | |||
card.style.display = 'none'; | |||
return; | |||
} | |||
/* Hide if the only remaining content is red links */ | |||
if ( redLinks.length > 0 && redLinks.length === links.length ) { | |||
clone.querySelectorAll( 'a' ).forEach( function ( a ) { a.remove(); } ); | |||
if ( clone.textContent.trim() === '' ) { | |||
card.style.display = 'none'; | |||
} | |||
} | |||
} ); | |||
} | |||
/* bswSetTab — exposed globally for inline onclick on span tabs */ | |||
window.bswSetTab = function ( el, wiki ) { | |||
document.querySelectorAll( '.bsw-wtab' ).forEach( function ( t ) { | |||
t.classList.remove( 'bsw-active' ); | |||
} ); | |||
el.classList.add( 'bsw-active' ); | |||
rcCurrentWiki = wiki; | |||
loadRecentChanges( wiki ); | |||
}; | |||
mw.hook( 'wikipage.content' ).add( function () { | |||
if ( !document.querySelector( '.bsw-main-page' ) ) return; | |||
initHero(); | |||
initFeatured(); | |||
initMediaFileCount(); | |||
filterRedlinkCards(); | |||
/* Cache the initial 'all' RC content so tab switching can restore it */ | |||
var rcWrap = document.getElementById( 'bsw-rc-content' ); | |||
if ( rcWrap ) rcCache.all = rcWrap.innerHTML; | |||
} ); | |||
}() ); | |||
/** | |||
* BattlestarWiki — Video of the Day loader | |||
* Append to MediaWiki:Common.js (after the main page JS block) | |||
* | |||
* Fetches from battlestarpegasus.com MediaCMS API. | |||
* Selects a video deterministically by date. | |||
* Supports manual override via Battlestar_Wiki:Video_of_the_Day/YYYY-MM-DD subpage. | |||
*/ | |||
( function () { | |||
'use strict'; | |||
var PEGASUS = 'https://battlestarpegasus.com'; | |||
var PAGE_SIZE = 50; | |||
function esc( s ) { | |||
return String( s || '' ) | |||
.replace( /&/g, '&' ).replace( /</g, '<' ) | |||
.replace( />/g, '>' ).replace( /"/g, '"' ); | |||
} | |||
function dailySeed() { | |||
var now = new Date(); | |||
return Math.floor( Date.UTC( | |||
now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() | |||
) / 86400000 ); | |||
} | |||
function fmtDuration( secs ) { | |||
secs = Math.round( secs || 0 ); | |||
var m = Math.floor( secs / 60 ), s = secs % 60; | |||
return m + ':' + ( s < 10 ? '0' : '' ) + s; | |||
} | |||
function pegasusGet( path ) { | |||
return fetch( PEGASUS + path, { headers: { Accept: 'application/json' } } ) | |||
.then( function ( r ) { | |||
if ( !r.ok ) throw new Error( 'HTTP ' + r.status ); | |||
return r.json(); | |||
} ); | |||
} | |||
function fetchAllVideos() { | |||
return pegasusGet( '/api/v1/media/?media_type=video&ordering=add_date&page_size=' + PAGE_SIZE ) | |||
.then( function ( data ) { | |||
var results = data.results || []; | |||
if ( data.next ) { | |||
return pegasusGet( '/api/v1/media/?media_type=video&ordering=add_date&page_size=' + PAGE_SIZE + '&page=2' ) | |||
.then( function ( d2 ) { return results.concat( d2.results || [] ); } ); | |||
} | |||
return results; | |||
} ); | |||
} | |||
function fetchVideoDetail( token ) { | |||
return pegasusGet( '/api/v1/media/' + token ); | |||
} | |||
function renderVotd( container, media ) { | |||
var token = media.friendly_token; | |||
var watchUrl = media.url || ( PEGASUS + '/view?m=' + token ); | |||
var thumb = media.thumbnail_url ? PEGASUS + media.thumbnail_url : ''; | |||
var poster = media.poster_url ? PEGASUS + media.poster_url : thumb; | |||
/* ── Build video URLs from API response ── */ | |||
var hlsSrc = ''; | |||
var mp4Src = ''; | |||
if ( media.hls_info && media.hls_info.master_file ) { | |||
hlsSrc = PEGASUS + media.hls_info.master_file; | |||
} | |||
/* Prefer 720p MP4 fallback, cascade down */ | |||
var enc = media.encodings_info || {}; | |||
var mp4Res = [ '720', '480', '360', '1080', '240' ]; | |||
for ( var i = 0; i < mp4Res.length; i++ ) { | |||
var r = enc[ mp4Res[i] ]; | |||
if ( r && r.h264 && r.h264.url && r.h264.status === 'success' ) { | |||
mp4Src = PEGASUS + r.h264.url; | |||
break; | |||
} | |||
} | |||
/* ── Player HTML: <video> with poster ── */ | |||
var videoId = 'bsw-votd-video'; | |||
var playerHtml = | |||
'<video id="' + videoId + '" ' + | |||
'style="width:100%;aspect-ratio:16/9;display:block;background:#000" ' + | |||
'controls playsinline preload="none" ' + | |||
( poster ? 'poster="' + esc( poster ) + '"' : '' ) + '>' + | |||
( mp4Src ? '<source src="' + esc( mp4Src ) + '" type="video/mp4">' : '' ) + | |||
'</video>'; | |||
/* ── Description sidebar ── */ | |||
var tags = ( media.tags || [] ).slice( 0, 8 ).map( function ( t ) { | |||
var label = typeof t === 'string' ? t : ( t.title || t.name || '' ); | |||
return label ? '<span class="bsw-votd-tag">' + esc( label ) + '</span>' : ''; | |||
} ).join( '' ); | |||
var stats = []; | |||
if ( media.duration ) stats.push( '\u23f1 ' + fmtDuration( media.duration ) ); | |||
if ( media.views ) stats.push( '\ud83d\udc41 ' + media.views.toLocaleString() ); | |||
if ( media.size ) stats.push( media.size ); | |||
var desc = media.description || ''; | |||
var infoHtml = | |||
'<div class="bsw-votd-title">' + esc( media.title || 'Untitled' ) + '</div>' + | |||
( stats.length ? '<div class="bsw-votd-stats">' + stats.join( ' \u00b7 ' ) + '</div>' : '' ) + | |||
( desc ? '<div class="bsw-votd-desc">' + desc + '</div>' : '' ) + | |||
( tags ? '<div class="bsw-votd-tags">' + tags + '</div>' : '' ) + | |||
'<div class="bsw-votd-actions">' + | |||
'<a class="bsw-votd-watch" href="' + esc( watchUrl ) + '" target="_blank" rel="noopener">Watch on Battlestar Pegasus \u2197</a>' + | |||
'<a class="bsw-votd-archive" href="/Battlestar_Wiki:Video_of_the_Day">Video archive</a>' + | |||
'</div>'; | |||
/* Inject into DOM */ | |||
var playerEl = container.querySelector( '.bsw-votd-player' ); | |||
var infoEl = document.getElementById( 'bsw-votd-info' ); | |||
if ( playerEl ) { | |||
playerEl.innerHTML = playerHtml; | |||
/* Wire HLS.js if HLS src available and browser needs it */ | |||
if ( hlsSrc ) { | |||
var videoEl = document.getElementById( videoId ); | |||
if ( videoEl ) { | |||
if ( videoEl.canPlayType( 'application/vnd.apple.mpegurl' ) ) { | |||
/* Native HLS (Safari) */ | |||
videoEl.src = hlsSrc; | |||
} else { | |||
/* Load hls.js dynamically from cdnjs */ | |||
var script = document.createElement( 'script' ); | |||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js'; | |||
script.onload = function () { | |||
if ( window.Hls && Hls.isSupported() ) { | |||
var hls = new Hls( { startLevel: 3 } ); /* start at 720p */ | |||
hls.loadSource( hlsSrc ); | |||
hls.attachMedia( videoEl ); | |||
} | |||
}; | |||
document.head.appendChild( script ); | |||
} | |||
} | |||
} | |||
} | |||
if ( infoEl ) { | |||
infoEl.innerHTML = infoHtml; | |||
infoEl.style.display = ''; | |||
} | |||
} | |||
function showVotdError( container, msg ) { | |||
var playerEl = container.querySelector( '.bsw-votd-player' ); | |||
if ( playerEl ) { | |||
playerEl.innerHTML = | |||
'<div class="bsw-error" style="padding:1.5rem;text-align:center">' + | |||
'Could not load video. ' + | |||
'<a href="' + esc( PEGASUS ) + '" target="_blank" rel="noopener">Visit Battlestar Pegasus \u2197</a>' + | |||
'</div>'; | |||
} | |||
} | |||
mw.hook( 'wikipage.content' ).add( function () { | |||
var container = document.getElementById( 'bsw-votd-container' ); | |||
if ( !container ) return; | |||
var dateStr = container.dataset.date || ''; | |||
var overrideEl = document.getElementById( 'bsw-votd-override' ); | |||
var override = overrideEl ? overrideEl.textContent.trim() : ''; | |||
var seed = ( function () { | |||
if ( dateStr ) { | |||
var p = dateStr.split( '-' ); | |||
return Math.floor( Date.UTC( +p[0], +p[1] - 1, +p[2] ) / 86400000 ); | |||
} | |||
return dailySeed(); | |||
}() ); | |||
if ( override ) { | |||
fetchVideoDetail( override ) | |||
.then( function ( media ) { renderVotd( container, media ); } ) | |||
.catch( function ( e ) { showVotdError( container, e.message ); } ); | |||
} else { | |||
fetchAllVideos() | |||
.then( function ( items ) { | |||
if ( !items.length ) throw new Error( 'No videos available.' ); | |||
var pick = items[ seed % items.length ]; | |||
return fetchVideoDetail( pick.friendly_token ); | |||
} ) | |||
.then( function ( media ) { renderVotd( container, media ); } ) | |||
.catch( function ( e ) { showVotdError( container, e.message ); } ); | |||
} | |||
} ); | |||
}() ); | |||
Latest revision as of 02:05, 12 April 2026
/** Interwiki links to featured articles ***************************************
*
* Description: Highlights interwiki links to featured articles (or
* equivalents) by changing the bullet before the interwiki link
* into a star.
* Maintainers: [[Wikipedia:User:R. Koot|R. Koot]]
*/
function LinkFA()
{
if ( document.getElementById( "p-lang" ) ) {
var InterwikiLinks = document.getElementById( "p-lang" ).getElementsByTagName( "li" );
for ( var i = 0; i < InterwikiLinks.length; i++ ) {
if ( document.getElementById( InterwikiLinks[i].className + "-fa" ) ) {
InterwikiLinks[i].className += " FA"
InterwikiLinks[i].title = "This is a featured article in another language.";
}
}
}
}
addOnloadHook( LinkFA );
/** Collapsible tables *********************************************************
*
* Description: Allows tables to be collapsed, showing only the header. See
* [[Wikipedia:Wikipedia:NavFrame|Wikipedia:NavFrame]].
* Maintainers: [[Wikipedia:User:R. Koot|R. Koot]]
*/
var autoCollapse = 2;
var collapseCaption = "hide";
var expandCaption = "show";
function collapseTable( tableIndex )
{
var Button = document.getElementById( "collapseButton" + tableIndex );
var Table = document.getElementById( "collapsibleTable" + tableIndex );
if ( !Table || !Button ) {
return false;
}
var Rows = Table.getElementsByTagName( "tr" );
if ( Button.firstChild.data == collapseCaption ) {
for ( var i = 1; i < Rows.length; i++ ) {
if(Rows[i].parentNode.parentNode.id == ("collapsibleTable" + tableIndex))
{
Rows[i].style.display = "none";
}
}
Button.firstChild.data = expandCaption;
} else {
for ( var i = 1; i < Rows.length; i++ ) {
if(Rows[i].parentNode.parentNode.id == ("collapsibleTable" + tableIndex))
{
Rows[i].style.display = Rows[0].style.display;
}
}
Button.firstChild.data = collapseCaption;
}
}
function createCollapseButtons()
{
var tableIndex = 0;
var NavigationBoxes = new Object();
var Tables = document.getElementsByTagName( "table" );
for ( var i = 0; i < Tables.length; i++ ) {
if ( hasClass( Tables[i], "collapsible" ) ) {
NavigationBoxes[ tableIndex ] = Tables[i];
Tables[i].setAttribute( "id", "collapsibleTable" + tableIndex );
var Button = document.createElement( "span" );
var ButtonLink = document.createElement( "a" );
var ButtonText = document.createTextNode( collapseCaption );
Button.style.styleFloat = "right";
Button.style.cssFloat = "right";
Button.style.fontWeight = "normal";
Button.style.textAlign = "right";
Button.style.width = "6em";
ButtonLink.setAttribute( "id", "collapseButton" + tableIndex );
ButtonLink.setAttribute( "href", "javascript:collapseTable(" + tableIndex + ");" );
ButtonLink.appendChild( ButtonText );
Button.appendChild( document.createTextNode( "[" ) );
Button.appendChild( ButtonLink );
Button.appendChild( document.createTextNode( "]" ) );
var Header = Tables[i].getElementsByTagName( "tr" )[0].getElementsByTagName( "th" )[0];
/* only add button and increment count if there is a header row to work with */
if (Header) {
Header.insertBefore( Button, Header.childNodes[0] );
tableIndex++;
}
}
}
for ( var i = 0; i < tableIndex; i++ ) {
if ( hasClass( NavigationBoxes[i], "collapsed" ) || ( tableIndex >= autoCollapse && hasClass( NavigationBoxes[i], "autocollapse" ) ) ) {
collapseTable( i );
}
}
}
addOnloadHook( createCollapseButtons );
/** Dynamic Navigation Bars (experimental) *************************************
*
* Description: See [[Wikipedia:NavFrame]].
* Maintainers: UNMAINTAINED
*/
// set up the words in your language
var NavigationBarHide = '[' + collapseCaption + ']';
var NavigationBarShow = '[' + expandCaption + ']';
// shows and hides content and picture (if available) of navigation bars
// Parameters:
// indexNavigationBar: the index of navigation bar to be toggled
function toggleNavigationBar(indexNavigationBar)
{
var NavToggle = document.getElementById("NavToggle" + indexNavigationBar);
var NavFrame = document.getElementById("NavFrame" + indexNavigationBar);
if (!NavFrame || !NavToggle) {
return false;
}
// if shown now
if (NavToggle.firstChild.data == NavigationBarHide) {
for (
var NavChild = NavFrame.firstChild;
NavChild != null;
NavChild = NavChild.nextSibling
) {
if ( hasClass( NavChild, 'NavPic' ) ) {
NavChild.style.display = 'none';
}
if ( hasClass( NavChild, 'NavContent') ) {
NavChild.style.display = 'none';
}
}
NavToggle.firstChild.data = NavigationBarShow;
// if hidden now
} else if (NavToggle.firstChild.data == NavigationBarShow) {
for (
var NavChild = NavFrame.firstChild;
NavChild != null;
NavChild = NavChild.nextSibling
) {
if (hasClass(NavChild, 'NavPic')) {
NavChild.style.display = 'block';
}
if (hasClass(NavChild, 'NavContent')) {
NavChild.style.display = 'block';
}
}
NavToggle.firstChild.data = NavigationBarHide;
}
}
// adds show/hide-button to navigation bars
function createNavigationBarToggleButton()
{
var indexNavigationBar = 0;
// iterate over all < div >-elements
var divs = document.getElementsByTagName("div");
for(
var i=0;
NavFrame = divs[i];
i++
) {
// if found a navigation bar
if (hasClass(NavFrame, "NavFrame")) {
indexNavigationBar++;
var NavToggle = document.createElement("a");
NavToggle.className = 'NavToggle';
NavToggle.setAttribute('id', 'NavToggle' + indexNavigationBar);
NavToggle.setAttribute('href', 'javascript:toggleNavigationBar(' + indexNavigationBar + ');');
var NavToggleText = document.createTextNode(NavigationBarHide);
for (
var NavChild = NavFrame.firstChild;
NavChild != null;
NavChild = NavChild.nextSibling
) {
if ( hasClass( NavChild, 'NavPic' ) || hasClass( NavChild, 'NavContent' ) ) {
if (NavChild.style.display == 'none') {
NavToggleText = document.createTextNode(NavigationBarShow);
break;
}
}
}
NavToggle.appendChild(NavToggleText);
// Find the NavHead and attach the toggle link (Must be this complicated because Moz's firstChild handling is borked)
for(
var j=0;
j < NavFrame.childNodes.length;
j++
) {
if (hasClass(NavFrame.childNodes[j], "NavHead")) {
NavFrame.childNodes[j].appendChild(NavToggle);
}
}
NavFrame.setAttribute('id', 'NavFrame' + indexNavigationBar);
}
}
}
addOnloadHook( createNavigationBarToggleButton );
/* Test if an element has a certain class **************************************
*
* Description: Uses regular expressions and caching for better performance.
* Maintainers: [[Wikipedia:User:Mike Dillon|Mike Dillon]], [[Wikipedia:User:R. Koot|R. Koot]], [[Wikipedia:User:SG|SG]]
*/
var hasClass = (function () {
var reCache = {};
return function (element, className) {
return (reCache[className] ? reCache[className] : (reCache[className] = new RegExp("(?:\\s|^)" + className + "(?:\\s|$)"))).test(element.className);
};
})();
/* Any JavaScript here will be loaded for all users on every page load. */
/** includePage ************
* force the loading of another JavaScript file
*
* Maintainer: [[Commons:User:Dschwen]]
*/
function includePage( name )
{
document.write('<script type="text/javascript" src="' + wgScript + '?title='
+ name
+ '&action=raw&ctype=text/javascript"><\/script>'
);
}
/* End of includePage */
/**
* BattlestarWiki — Main Page JavaScript
* Append to MediaWiki:Common.js
*
* Handles:
* 1. Hero slideshow auto-rotation + article image fetching
* 2. Featured Article of the Day — daily deterministic pick,
* excluding Category:Stub_Pages, with extract + thumbnail
* 3. Media wiki file count (from media.battlestarwiki.org)
* 4. Recent changes tab switching (EN / DE / Media wikis)
* 5. Red-link card filtering
*
* All logic is scoped to .bsw-main-page; safe to load globally.
*/
( function () {
'use strict';
var API = 'https://en.battlestarwiki.org/w/api.php';
var MEDIA_API = 'https://media.battlestarwiki.org/w/api.php';
var DE_API = 'https://de.battlestarwiki.org/w/api.php';
/* ── Shared utilities ─────────────────────────────────────────── */
function dailySeed( dateStr ) {
var d;
if ( dateStr ) {
var p = dateStr.split( '-' );
d = Date.UTC( +p[0], +p[1] - 1, +p[2] );
} else {
var now = new Date();
d = Date.UTC( now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() );
}
return Math.floor( d / 86400000 );
}
function esc( s ) {
return String( s || '' )
.replace( /&/g, '&' )
.replace( /</g, '<' )
.replace( />/g, '>' )
.replace( /"/g, '"' );
}
function apiGetFrom( baseUrl, params ) {
params.format = 'json';
params.origin = '*';
var qs = Object.keys( params )
.map( function ( k ) { return encodeURIComponent( k ) + '=' + encodeURIComponent( params[k] ); } )
.join( '&' );
return fetch( baseUrl + '?' + qs, { headers: { Accept: 'application/json' } } )
.then( function ( r ) {
if ( !r.ok ) throw new Error( 'API HTTP ' + r.status );
return r.json();
} );
}
function apiGet( params ) {
return apiGetFrom( API, params );
}
/* ════════════════════════════════════════════════════════════════
1. HERO SLIDESHOW
Dynamically populated from the wiki's most-viewed articles.
Uses a daily seed so all visitors see the same 3 slides each day.
Falls back to the static Lua-rendered slides if the API fails.
════════════════════════════════════════════════════════════════ */
/* Series classification for badge color + label */
var SERIES_CATS = [
{ pat: /\bRDM\b|\bTRS\b|Re-imagined/i, label: 'Re-imagined Series', color: 'var(--color-primary)' },
{ pat: /\bTOS\b|Original Series/i, label: 'Original Series', color: '#ef9f27' },
{ pat: /Galactica 1980|1980/i, label: 'Galactica 1980', color: '#97c459' },
{ pat: /Caprica/i, label: 'Caprica', color: '#d4537e' },
{ pat: /Blood.*Chrome|BAC/i, label: 'Blood & Chrome', color: '#e24b4a' }
];
function classifyArticle( title, cats ) {
/* Check category names first */
if ( cats ) {
for ( var i = 0; i < SERIES_CATS.length; i++ ) {
for ( var j = 0; j < cats.length; j++ ) {
if ( SERIES_CATS[i].pat.test( cats[j].title ) ) return SERIES_CATS[i];
}
}
}
/* Fall back to title matching */
for ( var k = 0; k < SERIES_CATS.length; k++ ) {
if ( SERIES_CATS[k].pat.test( title ) ) return SERIES_CATS[k];
}
return { label: 'Battlestar Wiki', color: 'var(--color-primary)' };
}
function buildSlideHTML( article, series, imgUrl ) {
var title = article.title;
var url = '/wiki/' + encodeURIComponent( title.replace( / /g, '_' ) );
var desc = ( article.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 160 );
if ( desc.length === 160 ) desc = desc.replace( /\s\S+$/, '' ) + '…';
var badgeStyle = series.color ? ' style="background:' + series.color + '"' : '';
return '<div class="bsw-slide bsw-active" data-article="' + esc( title ) + '">' +
'<div class="bsw-slide-bg"></div>' +
'<div class="bsw-slide-overlay"></div>' +
'<div class="bsw-slide-content">' +
'<div class="bsw-slide-badge">' +
'<span class="bsw-slide-badge-dot"' + badgeStyle + '></span>' +
'<span>' + esc( series.label ) + '</span>' +
'</div>' +
'<div class="bsw-slide-title"><a href="' + esc( url ) + '">' + esc( title ) + '</a></div>' +
'<div class="bsw-slide-desc">' + esc( desc ) + '</div>' +
'</div>' +
'</div>';
}
function buildDotsAndNav( count ) {
var dots = '<div class="bsw-hero-dots">';
for ( var i = 0; i < count; i++ ) {
var cls = 'bsw-hero-dot' + ( i === 0 ? ' bsw-active' : '' );
dots += '<span class="' + cls + '" role="button" tabindex="0" onclick="bswGoSlide(' + i + ')"></span>';
}
dots += '</div>';
var nav = '<div class="bsw-hero-nav">' +
'<span class="bsw-hero-btn" role="button" tabindex="0" id="bsw-hero-prev" onclick="bswPrevSlide()">‹</span>' +
'<span class="bsw-hero-btn" role="button" tabindex="0" id="bsw-hero-next" onclick="bswNextSlide()">›</span>' +
'</div>';
return dots + nav;
}
function initHeroSlideshow( hero, slides ) {
var dots = hero.querySelectorAll( '.bsw-hero-dot' );
var current = 0;
var total = slides.length;
var timer;
function goTo( n ) {
slides[ current ].classList.remove( 'bsw-active' );
dots[ current ] && dots[ current ].classList.remove( 'bsw-active' );
current = ( n + total ) % total;
slides[ current ].classList.add( 'bsw-active' );
dots[ current ] && dots[ current ].classList.add( 'bsw-active' );
resetTimer();
}
function resetTimer() {
clearInterval( timer );
timer = setInterval( function () { goTo( current + 1 ); }, 6000 );
}
window.bswGoSlide = function ( n ) { goTo( n ); };
window.bswPrevSlide = function () { goTo( current - 1 ); };
window.bswNextSlide = function () { goTo( current + 1 ); };
dots.forEach( function ( dot, i ) {
dot.addEventListener( 'click', function () { goTo( i ); } );
} );
var prevBtn = document.getElementById( 'bsw-hero-prev' );
var nextBtn = document.getElementById( 'bsw-hero-next' );
if ( prevBtn ) prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } );
if ( nextBtn ) nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } );
hero.addEventListener( 'mouseenter', function () { clearInterval( timer ); } );
hero.addEventListener( 'mouseleave', resetTimer );
resetTimer();
}
function fetchSlideImages( hero, slides ) {
var titles = Array.from( slides ).map( function ( s ) { return s.dataset.article; } ).filter( Boolean );
if ( !titles.length ) return;
apiGet( {
action: 'query',
titles: titles.join( '|' ),
prop: 'pageimages',
piprop: 'thumbnail|original',
pithumbsize: '1200',
redirects: '1'
} ).then( function ( data ) {
var pages = data.query.pages;
var imageMap = {};
var normalized = {};
if ( data.query.normalized ) {
data.query.normalized.forEach( function ( n ) { normalized[ n.to ] = n.from; } );
}
var redirects = {};
if ( data.query.redirects ) {
data.query.redirects.forEach( function ( r ) { redirects[ r.to ] = r.from; } );
}
Object.values( pages ).forEach( function ( page ) {
if ( !page.title ) return;
var img = ( page.original && page.original.source ) ||
( page.thumbnail && page.thumbnail.source ) || '';
if ( !img ) return;
imageMap[ page.title ] = img;
if ( redirects[ page.title ] ) imageMap[ redirects[ page.title ] ] = img;
if ( normalized[ page.title ] ) imageMap[ normalized[ page.title ] ] = img;
} );
slides.forEach( function ( slide ) {
var imgUrl = imageMap[ slide.dataset.article ];
if ( !imgUrl ) return;
var bg = slide.querySelector( '.bsw-slide-bg' );
if ( !bg ) return;
var bgImg = document.createElement( 'img' );
bgImg.src = imgUrl;
bgImg.alt = '';
bgImg.className = 'bsw-slide-bg-blur';
bgImg.setAttribute( 'aria-hidden', 'true' );
var fgImg = document.createElement( 'img' );
fgImg.src = imgUrl;
fgImg.alt = '';
fgImg.className = 'bsw-slide-bg-img';
bg.appendChild( bgImg );
bg.appendChild( fgImg );
} );
} ).catch( function () {} );
}
function initHero() {
var hero = document.querySelector( '.bsw-hero' );
if ( !hero ) return;
var seed = dailySeed();
var SLIDE_COUNT = 3;
var FETCH_BATCH = 20; /* fetch more than needed so we can filter for ones with images */
/* Fetch random mainspace articles, then filter to those with images */
apiGet( {
action: 'query',
list: 'random',
rnnamespace: '0',
rnlimit: String( FETCH_BATCH )
} ).then( function ( data ) {
var items = ( data.query.random || [] ).filter( function ( p ) {
return p.title !== 'Main Page' && p.title !== 'Main Page/New';
} );
if ( !items.length ) throw new Error( 'No articles' );
/* Fetch extracts + images + categories for all candidates in one call */
return apiGet( {
action: 'query',
titles: items.map( function ( p ) { return p.title; } ).join( '|' ),
prop: 'extracts|pageimages|categories',
exintro: '1',
exchars: '200',
exsectionformat: 'plain',
piprop: 'thumbnail|original',
pithumbsize: '1200',
cllimit: '20',
clcategories: 'Category:Disambiguation|Category:Stub_Pages',
redirects: '1'
} );
} ).then( function ( data ) {
var pages = Object.values( data.query.pages || {} );
/* Keep only articles that have an image and aren't disambiguation pages */
var withImages = pages.filter( function ( p ) {
if ( !p.thumbnail && !p.original ) return false;
var cats = ( p.categories || [] ).map( function ( c ) { return c.title; } );
return !cats.some( function ( c ) { return c === 'Category:Disambiguation' || /[Ss]tub/.test( c ); } );
} );
/* Fall back to all non-disambiguation pages if not enough have images */
var pool = withImages.length >= SLIDE_COUNT ? withImages : pages.filter( function ( p ) {
var cats = ( p.categories || [] ).map( function ( c ) { return c.title; } );
return !cats.some( function ( c ) { return c === 'Category:Disambiguation' || /[Ss]tub/.test( c ); } );
} );
if ( pool.length < SLIDE_COUNT ) throw new Error( 'Not enough articles' );
/* Pick SLIDE_COUNT articles using daily seed with spacing */
var picks = [];
for ( var i = 0; i < SLIDE_COUNT; i++ ) {
picks.push( pool[ ( seed + i * 7 ) % pool.length ] );
}
/* Build slide HTML */
var slideHTML = picks.map( function ( article, i ) {
var cats = article.categories || [];
var series = classifyArticle( article.title, cats );
var html = buildSlideHTML( article, series, '' );
return i === 0 ? html : html.replace( 'bsw-slide bsw-active', 'bsw-slide' );
} ).join( '' );
hero.innerHTML = slideHTML + buildDotsAndNav( picks.length );
var newSlides = hero.querySelectorAll( '.bsw-slide' );
initHeroSlideshow( hero, newSlides );
/* Inject images directly from the data we already fetched */
picks.forEach( function ( article, i ) {
var imgUrl = ( article.original && article.original.source ) ||
( article.thumbnail && article.thumbnail.source ) || '';
if ( !imgUrl ) return;
var bg = newSlides[ i ] && newSlides[ i ].querySelector( '.bsw-slide-bg' );
if ( !bg ) return;
var bgImg = document.createElement( 'img' );
bgImg.src = imgUrl;
bgImg.alt = '';
bgImg.className = 'bsw-slide-bg-blur';
bgImg.setAttribute( 'aria-hidden', 'true' );
var fgImg = document.createElement( 'img' );
fgImg.src = imgUrl;
fgImg.alt = '';
fgImg.className = 'bsw-slide-bg-img';
bg.appendChild( bgImg );
bg.appendChild( fgImg );
} );
} ).catch( function () {
/* Fallback: use static Lua-rendered slides */
var staticSlides = hero.querySelectorAll( '.bsw-slide' );
if ( staticSlides.length ) {
staticSlides[0].classList.add( 'bsw-active' );
initHeroSlideshow( hero, staticSlides );
fetchSlideImages( hero, staticSlides );
}
} );
}
/* ════════════════════════════════════════════════════════════════
2. FEATURED ARTICLE OF THE DAY
Daily deterministic pick from all mainspace articles,
excluding Category:Stub_Pages.
════════════════════════════════════════════════════════════════ */
function initFeatured() {
var container = document.getElementById( 'bsw-featured-inner' );
if ( !container ) return;
var seed = dailySeed();
/* We fetch batches of 20 random articles, check for stubs,
and pick the first clean one. Two batches covers ~99.99%
of cases given only ~9% of articles are stubs. */
fetchCandidateBatch( seed )
.then( function ( pages ) {
var clean = pages.filter( function ( p ) {
/* If categories array is present and contains Stub_Pages,
exclude. Absence of categories key means not a stub. */
if ( !p.categories ) return true;
return !p.categories.some( function ( c ) {
return c.title === 'Category:Stub_Pages';
} );
} );
if ( !clean.length ) {
/* Extremely unlikely — fall back to second batch */
return fetchCandidateBatch( seed + 1 );
}
return clean;
} )
.then( function ( pages ) {
/* Pick deterministically from the clean pool */
var pick = pages[ seed % pages.length ];
renderFeatured( container, pick );
} )
.catch( function ( e ) {
container.innerHTML =
'<div class="bsw-error">Could not load featured article. ' + esc( e.message ) + '</div>';
} );
}
/**
* Fetch 20 random mainspace articles with extract, thumbnail,
* and stub-category membership check in a single API call.
* Uses rnstart derived from seed for determinism.
*/
function fetchCandidateBatch( seed ) {
/* Step 1: get 20 random page IDs */
return apiGet( {
action: 'query',
list: 'random',
rnnamespace: '0',
rnlimit: '20'
} ).then( function ( data ) {
var ids = data.query.random.map( function ( p ) { return p.id; } ).join( '|' );
/* Step 2: batch fetch extract + thumbnail + stub check */
return apiGet( {
action: 'query',
pageids: ids,
prop: 'extracts|pageimages|categories',
exintro: '1',
exchars: '600',
exsectionformat: 'plain',
piprop: 'thumbnail',
pithumbsize: '320',
clcategories: 'Category:Stub_Pages',
cllimit: '1'
} );
} ).then( function ( data ) {
return Object.values( data.query.pages );
} );
}
function renderFeatured( container, page ) {
var title = page.title || '';
var url = 'https://en.battlestarwiki.org/wiki/' + encodeURIComponent( title.replace( / /g, '_' ) );
var extract = page.extract || '';
var thumb = page.thumbnail ? page.thumbnail.source : '';
var thumbHtml;
if ( thumb ) {
thumbHtml = '<img class="bsw-fa-thumb" src="' + esc( thumb ) + '" alt="' + esc( title ) + '">';
} else {
thumbHtml = '<img class="bsw-fa-thumb" src="https://en.battlestarwiki.org/resources/BSGWIKILOGO.png" alt="Battlestar Wiki">';
}
container.innerHTML =
thumbHtml +
'<div>' +
'<div class="bsw-fa-title">' +
'<a href="' + esc( url ) + '">' + esc( title ) + '</a>' +
'</div>' +
'<div class="bsw-fa-extract">' + extract + '</div>' +
'<div style="margin-top:0.375rem">' +
'<a href="' + esc( url ) + '" style="font-size:0.75rem;color:var(--color-link)">Read more \u2192</a>' +
'</div>' +
'</div>';
/* Update the "Read more" span in the card header to link to the article */
var hdrLink = document.getElementById( 'bsw-featured-link' );
if ( hdrLink ) {
/* Replace the span with a proper anchor now that we have a URL */
var a = document.createElement( 'a' );
a.href = url;
a.textContent = 'Read more \u2192';
hdrLink.parentNode.replaceChild( a, hdrLink );
}
}
/**
* Fetch file count from media.battlestarwiki.org and populate
* the #bsw-stat-files span in the statistics card.
*/
function initMediaFileCount() {
var el = document.getElementById( 'bsw-stat-files' );
if ( !el ) return;
apiGetFrom( MEDIA_API, {
action: 'query',
meta: 'siteinfo',
siprop: 'statistics'
} ).then( function ( data ) {
var files = data.query.statistics.images || 0;
el.textContent = files.toLocaleString();
} ).catch( function () {
el.textContent = '—';
} );
}
/**
* Recent changes tab switching.
* Fetches live recent changes from the selected wiki's API
* and replaces the #bsw-rc-content div content.
*/
var rcCurrentWiki = 'all';
var rcCache = {};
function loadRecentChanges( wiki ) {
var wrap = document.getElementById( 'bsw-rc-content' );
if ( !wrap ) return;
if ( rcCache[ wiki ] ) {
wrap.innerHTML = rcCache[ wiki ];
return;
}
/* 'all' shows the cached Special:RecentChanges transclude */
if ( wiki === 'all' ) {
if ( rcCache.all ) {
wrap.innerHTML = rcCache.all;
}
return;
}
wrap.innerHTML = '<p style="color:var(--color-base--subtle);font-size:0.8125rem;padding:0.5rem 0">Loading\u2026</p>';
var apiMap = { en: API, de: DE_API, media: MEDIA_API };
var base = apiMap[ wiki ];
if ( !base ) return;
apiGetFrom( base, {
action: 'query',
list: 'recentchanges',
rcprop: 'title|timestamp|user|sizes',
rcnamespace: '0',
rclimit: '8',
rctype: 'edit|new'
} ).then( function ( data ) {
var changes = data.query.recentchanges || [];
if ( !changes.length ) {
wrap.innerHTML = '<p style="color:var(--color-base--subtle);font-size:0.8125rem;padding:0.5rem 0">No recent changes.</p>';
return;
}
var baseUrl = {
en: 'https://en.battlestarwiki.org/wiki/',
de: 'https://de.battlestarwiki.org/wiki/',
media: 'https://media.battlestarwiki.org/wiki/'
}[ wiki ];
var html = '<ul class="bsw-recent">' + changes.map( function ( rc ) {
var title = rc.title;
var url = baseUrl + encodeURIComponent( title.replace( / /g, '_' ) );
var ts = new Date( rc.timestamp );
var ago = timeAgo( ts );
return '<li class="ri">' +
'<a href="' + esc( url ) + '" target="_blank">' + esc( title ) + '</a>' +
'<span class="bsw-recent-time">' + esc( ago ) + '</span>' +
'</li>';
} ).join( '' ) + '</ul>';
rcCache[ wiki ] = html;
wrap.innerHTML = html;
} ).catch( function () {
wrap.innerHTML = '<p style="color:var(--color-base--subtle);font-size:0.8125rem;padding:0.5rem 0">Could not load recent changes.</p>';
} );
}
function timeAgo( date ) {
var diff = Math.floor( ( Date.now() - date.getTime() ) / 1000 );
if ( diff < 60 ) return diff + 's ago';
if ( diff < 3600 ) return Math.floor( diff / 60 ) + ' min ago';
if ( diff < 86400 ) return Math.floor( diff / 3600 ) + ' hr ago';
return Math.floor( diff / 86400 ) + ' days ago';
}
/* ── Entry point ──────────────────────────────────────────────── */
/**
* Hide any .bsw-card whose .bsw-card-body contains nothing but
* a red link (missing page transclusion). This catches subpages
* like BattlestarWiki:Did_you_know, OTD/Month_Day etc. that
* haven't been created yet.
*/
function filterRedlinkCards() {
document.querySelectorAll( '.bsw-main-page .bsw-card' ).forEach( function ( card ) {
var body = card.querySelector( '.bsw-card-body' );
if ( !body ) return;
/* Remove non-content elements before checking — date labels,
loading spinners etc. shouldn't count as "content" */
var clone = body.cloneNode( true );
/* Remove known non-content wrappers */
clone.querySelectorAll( '.bsw-otd-date, .bsw-spinner, .bsw-loading' )
.forEach( function ( el ) { el.remove(); } );
var text = clone.textContent.trim();
var links = clone.querySelectorAll( 'a' );
var redLinks = clone.querySelectorAll( 'a.new' );
/* Hide if nothing left after stripping non-content */
if ( text === '' ) {
card.style.display = 'none';
return;
}
/* Hide if the only remaining content is red links */
if ( redLinks.length > 0 && redLinks.length === links.length ) {
clone.querySelectorAll( 'a' ).forEach( function ( a ) { a.remove(); } );
if ( clone.textContent.trim() === '' ) {
card.style.display = 'none';
}
}
} );
}
/* bswSetTab — exposed globally for inline onclick on span tabs */
window.bswSetTab = function ( el, wiki ) {
document.querySelectorAll( '.bsw-wtab' ).forEach( function ( t ) {
t.classList.remove( 'bsw-active' );
} );
el.classList.add( 'bsw-active' );
rcCurrentWiki = wiki;
loadRecentChanges( wiki );
};
mw.hook( 'wikipage.content' ).add( function () {
if ( !document.querySelector( '.bsw-main-page' ) ) return;
initHero();
initFeatured();
initMediaFileCount();
filterRedlinkCards();
/* Cache the initial 'all' RC content so tab switching can restore it */
var rcWrap = document.getElementById( 'bsw-rc-content' );
if ( rcWrap ) rcCache.all = rcWrap.innerHTML;
} );
}() );
/**
* BattlestarWiki — Video of the Day loader
* Append to MediaWiki:Common.js (after the main page JS block)
*
* Fetches from battlestarpegasus.com MediaCMS API.
* Selects a video deterministically by date.
* Supports manual override via Battlestar_Wiki:Video_of_the_Day/YYYY-MM-DD subpage.
*/
( function () {
'use strict';
var PEGASUS = 'https://battlestarpegasus.com';
var PAGE_SIZE = 50;
function esc( s ) {
return String( s || '' )
.replace( /&/g, '&' ).replace( /</g, '<' )
.replace( />/g, '>' ).replace( /"/g, '"' );
}
function dailySeed() {
var now = new Date();
return Math.floor( Date.UTC(
now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()
) / 86400000 );
}
function fmtDuration( secs ) {
secs = Math.round( secs || 0 );
var m = Math.floor( secs / 60 ), s = secs % 60;
return m + ':' + ( s < 10 ? '0' : '' ) + s;
}
function pegasusGet( path ) {
return fetch( PEGASUS + path, { headers: { Accept: 'application/json' } } )
.then( function ( r ) {
if ( !r.ok ) throw new Error( 'HTTP ' + r.status );
return r.json();
} );
}
function fetchAllVideos() {
return pegasusGet( '/api/v1/media/?media_type=video&ordering=add_date&page_size=' + PAGE_SIZE )
.then( function ( data ) {
var results = data.results || [];
if ( data.next ) {
return pegasusGet( '/api/v1/media/?media_type=video&ordering=add_date&page_size=' + PAGE_SIZE + '&page=2' )
.then( function ( d2 ) { return results.concat( d2.results || [] ); } );
}
return results;
} );
}
function fetchVideoDetail( token ) {
return pegasusGet( '/api/v1/media/' + token );
}
function renderVotd( container, media ) {
var token = media.friendly_token;
var watchUrl = media.url || ( PEGASUS + '/view?m=' + token );
var thumb = media.thumbnail_url ? PEGASUS + media.thumbnail_url : '';
var poster = media.poster_url ? PEGASUS + media.poster_url : thumb;
/* ── Build video URLs from API response ── */
var hlsSrc = '';
var mp4Src = '';
if ( media.hls_info && media.hls_info.master_file ) {
hlsSrc = PEGASUS + media.hls_info.master_file;
}
/* Prefer 720p MP4 fallback, cascade down */
var enc = media.encodings_info || {};
var mp4Res = [ '720', '480', '360', '1080', '240' ];
for ( var i = 0; i < mp4Res.length; i++ ) {
var r = enc[ mp4Res[i] ];
if ( r && r.h264 && r.h264.url && r.h264.status === 'success' ) {
mp4Src = PEGASUS + r.h264.url;
break;
}
}
/* ── Player HTML: <video> with poster ── */
var videoId = 'bsw-votd-video';
var playerHtml =
'<video id="' + videoId + '" ' +
'style="width:100%;aspect-ratio:16/9;display:block;background:#000" ' +
'controls playsinline preload="none" ' +
( poster ? 'poster="' + esc( poster ) + '"' : '' ) + '>' +
( mp4Src ? '<source src="' + esc( mp4Src ) + '" type="video/mp4">' : '' ) +
'</video>';
/* ── Description sidebar ── */
var tags = ( media.tags || [] ).slice( 0, 8 ).map( function ( t ) {
var label = typeof t === 'string' ? t : ( t.title || t.name || '' );
return label ? '<span class="bsw-votd-tag">' + esc( label ) + '</span>' : '';
} ).join( '' );
var stats = [];
if ( media.duration ) stats.push( '\u23f1 ' + fmtDuration( media.duration ) );
if ( media.views ) stats.push( '\ud83d\udc41 ' + media.views.toLocaleString() );
if ( media.size ) stats.push( media.size );
var desc = media.description || '';
var infoHtml =
'<div class="bsw-votd-title">' + esc( media.title || 'Untitled' ) + '</div>' +
( stats.length ? '<div class="bsw-votd-stats">' + stats.join( ' \u00b7 ' ) + '</div>' : '' ) +
( desc ? '<div class="bsw-votd-desc">' + desc + '</div>' : '' ) +
( tags ? '<div class="bsw-votd-tags">' + tags + '</div>' : '' ) +
'<div class="bsw-votd-actions">' +
'<a class="bsw-votd-watch" href="' + esc( watchUrl ) + '" target="_blank" rel="noopener">Watch on Battlestar Pegasus \u2197</a>' +
'<a class="bsw-votd-archive" href="/Battlestar_Wiki:Video_of_the_Day">Video archive</a>' +
'</div>';
/* Inject into DOM */
var playerEl = container.querySelector( '.bsw-votd-player' );
var infoEl = document.getElementById( 'bsw-votd-info' );
if ( playerEl ) {
playerEl.innerHTML = playerHtml;
/* Wire HLS.js if HLS src available and browser needs it */
if ( hlsSrc ) {
var videoEl = document.getElementById( videoId );
if ( videoEl ) {
if ( videoEl.canPlayType( 'application/vnd.apple.mpegurl' ) ) {
/* Native HLS (Safari) */
videoEl.src = hlsSrc;
} else {
/* Load hls.js dynamically from cdnjs */
var script = document.createElement( 'script' );
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js';
script.onload = function () {
if ( window.Hls && Hls.isSupported() ) {
var hls = new Hls( { startLevel: 3 } ); /* start at 720p */
hls.loadSource( hlsSrc );
hls.attachMedia( videoEl );
}
};
document.head.appendChild( script );
}
}
}
}
if ( infoEl ) {
infoEl.innerHTML = infoHtml;
infoEl.style.display = '';
}
}
function showVotdError( container, msg ) {
var playerEl = container.querySelector( '.bsw-votd-player' );
if ( playerEl ) {
playerEl.innerHTML =
'<div class="bsw-error" style="padding:1.5rem;text-align:center">' +
'Could not load video. ' +
'<a href="' + esc( PEGASUS ) + '" target="_blank" rel="noopener">Visit Battlestar Pegasus \u2197</a>' +
'</div>';
}
}
mw.hook( 'wikipage.content' ).add( function () {
var container = document.getElementById( 'bsw-votd-container' );
if ( !container ) return;
var dateStr = container.dataset.date || '';
var overrideEl = document.getElementById( 'bsw-votd-override' );
var override = overrideEl ? overrideEl.textContent.trim() : '';
var seed = ( function () {
if ( dateStr ) {
var p = dateStr.split( '-' );
return Math.floor( Date.UTC( +p[0], +p[1] - 1, +p[2] ) / 86400000 );
}
return dailySeed();
}() );
if ( override ) {
fetchVideoDetail( override )
.then( function ( media ) { renderVotd( container, media ); } )
.catch( function ( e ) { showVotdError( container, e.message ); } );
} else {
fetchAllVideos()
.then( function ( items ) {
if ( !items.length ) throw new Error( 'No videos available.' );
var pick = items[ seed % items.length ];
return fetchVideoDetail( pick.friendly_token );
} )
.then( function ( media ) { renderVotd( container, media ); } )
.catch( function ( e ) { showVotdError( container, e.message ); } );
}
} );
}() );