Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

MediaWiki:Common.js: Difference between revisions

MediaWiki interface page
Joe Beaudoin Jr. (talk | contribs)
No edit summary
Tag: Reverted
Joe Beaudoin Jr. (talk | contribs)
No edit summary
 
(23 intermediate revisions by the same user not shown)
Line 250: Line 250:
/* End of includePage */
/* End of includePage */


/* Reading Time */
/**
* 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 readingTime() {
( function () {
  const text = document.getElementById("article-body").innerText
    'use strict';
  const wpm = 225
  const words = text.trim().split(/\s+/).length
  const time = Math.ceil(words / wpm)
  document.getElementById("reading-time").innerText = time
}
  readingTime()


/* End Reading Time */
    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, '&lt;' )
            .replace( />/g, '&gt;' )
            .replace( /"/g, '&quot;' );
    }
 
    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()">&#8249;</span>' +
            '<span class="bsw-hero-btn" role="button" tabindex="0" id="bsw-hero-next" onclick="bswNextSlide()">&#8250;</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, '&amp;' ).replace( /</g, '&lt;' )
            .replace( />/g, '&gt;' ).replace( /"/g, '&quot;' );
    }
 
    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( ' &nbsp;\u00b7&nbsp; ' ) + '</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, '&amp;' )
            .replace( /</g, '&lt;' )
            .replace( />/g, '&gt;' )
            .replace( /"/g, '&quot;' );
    }

    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()">&#8249;</span>' +
            '<span class="bsw-hero-btn" role="button" tabindex="0" id="bsw-hero-next" onclick="bswNextSlide()">&#8250;</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, '&amp;' ).replace( /</g, '&lt;' )
            .replace( />/g, '&gt;' ).replace( /"/g, '&quot;' );
    }

    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( ' &nbsp;\u00b7&nbsp; ' ) + '</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 ); } );
        }
    } );

}() );