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-mainpage.js: Difference between revisions

MediaWiki interface page
Joe Beaudoin Jr. (talk | contribs)
No edit summary
Joe Beaudoin Jr. (talk | contribs)
Joe Beaudoin Jr. changed the content model of the page MediaWiki:Common-mainpage.js from "wikitext" to "JavaScript"
 
(6 intermediate revisions by the same user not shown)
Line 18: Line 18:


     var API      = 'https://en.battlestarwiki.org/w/api.php';
     var API      = 'https://en.battlestarwiki.org/w/api.php';
    var FR_API    = 'https://fr.battlestarwiki.ddns.net/api.php';
     var MEDIA_API = 'https://media.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';
     var DE_API    = 'https://de.battlestarwiki.org/w/api.php';
Line 107: Line 108:
         var badgeStyle = series.color ? ' style="background:' + series.color + '"' : '';
         var badgeStyle = series.color ? ' style="background:' + series.color + '"' : '';


         return '<div class="bsw-slide bsw-active" data-article="' + esc( title ) + '">' +
         return '<div class="bsw-slide bsw-active" data-article="' + esc( title ) + '" data-url="' + esc( url ) + '">' +
             '<div class="bsw-slide-bg"></div>' +
             '<div class="bsw-slide-bg"></div>' +
             '<div class="bsw-slide-overlay"></div>' +
             '<div class="bsw-slide-overlay"></div>' +
Line 158: Line 159:
         window.bswPrevSlide = function () { goTo( current - 1 ); };
         window.bswPrevSlide = function () { goTo( current - 1 ); };
         window.bswNextSlide = function () { goTo( current + 1 ); };
         window.bswNextSlide = function () { goTo( current + 1 ); };
        /* Click on image area (overlay or bg) navigates to article */
        slides.forEach( function ( slide ) {
            [ '.bsw-slide-overlay', '.bsw-slide-bg' ].forEach( function ( sel ) {
                var el = slide.querySelector( sel );
                if ( !el ) return;
                el.style.cursor = 'pointer';
                el.addEventListener( 'click', function () {
                    var url = slide.getAttribute( 'data-url' );
                    if ( url ) window.location.href = url;
                } );
            } );
        } );


         dots.forEach( function ( dot, i ) {
         dots.forEach( function ( dot, i ) {
Line 502: Line 516:
         wrap.innerHTML = '<p style="color:var(--color-base--subtle);font-size:0.8125rem;padding:0.5rem 0">Loading\u2026</p>';
         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 apiMap = { en: API, de: DE_API, fr: FR_API, media: MEDIA_API };
         var base = apiMap[ wiki ];
         var base = apiMap[ wiki ];
         if ( !base ) return;
         if ( !base ) return;
Line 509: Line 523:
             en:    '0',
             en:    '0',
             de:    '0',
             de:    '0',
             media: '0|6' /* articles + files */
            fr:    '0',
             media: '0|6'
         };
         };


Line 533: Line 548:


             var baseUrl = {
             var baseUrl = {
                 en:    'https://en.battlestarwiki.org/wiki/',
                 en:    'https://en.battlestarwiki.org/',
                 de:    'https://de.battlestarwiki.org/wiki/',
                 de:    'https://de.battlestarwiki.org/',
                 media: 'https://media.battlestarwiki.org/wiki/'
                fr:    'https://fr.battlestarwiki.ddns.net/wiki/',
                 media: 'https://media.battlestarwiki.org/'
             }[ wiki ];
             }[ wiki ];


Line 731: Line 747:


     function wireRcTabs() {
     function wireRcTabs() {
         var tabMap = { 'All': 'all', 'EN': 'en', 'DE': 'de', 'Media': 'media' };
         var tabMap = { 'All': 'all', 'EN': 'en', 'DE': 'de', 'FR': 'fr', 'Media': 'media' };
         document.querySelectorAll( '.bsw-wtab' ).forEach( function ( tab ) {
         document.querySelectorAll( '.bsw-wtab' ).forEach( function ( tab ) {
             var wiki = tabMap[ tab.textContent.trim() ];
             var wiki = tabMap[ tab.textContent.trim() ];

Latest revision as of 03:17, 13 April 2026

/**
 * 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 FR_API    = 'https://fr.battlestarwiki.ddns.net/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: /\(RDM\)|\(TRS\)|Re-imagined|Reimagined/i,                    label: 'Re-imagined Series', color: 'var(--color-primary)' },
        { pat: /\(TOS\)|Original Series|\bTOS\b/i,                            label: 'Original Series',    color: '#ef9f27' },
        { pat: /\(1980\)|Galactica.?1980|Category:1980\b/i,                   label: 'Galactica 1980',     color: '#97c459' },
        { pat: /\(Caprica\)|Caprica \(series\)|Category:Caprica\b/i,          label: 'Caprica',            color: '#d4537e' },
        { pat: /\(BAC\)|Blood and Chrome|Blood.*Chrome|\bBAC\b/i,             label: 'Blood & Chrome',     color: '#e24b4a' }
    ];

    function classifyArticle( title, cats, extract ) {
        /* 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];
        }
        /* Final fallback: scan extract for series keywords */
        if ( extract ) {
            var text = extract.replace( /<[^>]+>/g, '' );
            for ( var m = 0; m < SERIES_CATS.length; m++ ) {
                if ( SERIES_CATS[m].pat.test( text ) ) return SERIES_CATS[m];
            }
        }
        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 ) + '" data-url="' + esc( url ) + '">' +
            '<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 ); };

        /* Click on image area (overlay or bg) navigates to article */
        slides.forEach( function ( slide ) {
            [ '.bsw-slide-overlay', '.bsw-slide-bg' ].forEach( function ( sel ) {
                var el = slide.querySelector( sel );
                if ( !el ) return;
                el.style.cursor = 'pointer';
                el.addEventListener( 'click', function () {
                    var url = slide.getAttribute( 'data-url' );
                    if ( url ) window.location.href = url;
                } );
            } );
        } );

        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; /* MW caps rnlimit at 20 for non-bots */

        /* Fetch two batches of random articles for a larger candidate pool */
        Promise.all( [
            apiGet( { action: 'query', list: 'random', rnnamespace: '0', rnlimit: String( FETCH_BATCH ) } ),
            apiGet( { action: 'query', list: 'random', rnnamespace: '0', rnlimit: String( FETCH_BATCH ) } )
        ] ).then( function ( results ) {
            var combined = results[0].query.random.concat( results[1].query.random );
            /* Deduplicate and filter */
            var seen = {};
            var items = combined.filter( function ( p ) {
                if ( seen[ p.title ] ) return false;
                seen[ p.title ] = true;
                return p.title !== 'Main Page' &&
                       p.title !== 'Main Page/New' &&
                       p.title.indexOf( '/' ) === -1;
            } );
            if ( !items.length ) throw new Error( 'No articles' );

            /* Cap at 20 titles to stay within MW API limits */
            items = items.slice( 0, 20 );

            /* 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|pageprops',
                exintro:     '1',
                exchars:     '200',
                exsectionformat: 'plain',
                piprop:      'thumbnail|original',
                pithumbsize: '1200',
                cllimit:     '500',
                ppprop:      'disambiguation',
                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; } );
                var isDisamb = ( p.pageprops && p.pageprops.disambiguation !== undefined ) ||
                    cats.some( function ( c ) { return c === 'Category:Disambiguation'; } );
                var isStub = cats.some( function ( c ) { return /[Ss]tub/.test( c ); } );
                return !isDisamb && !isStub;
            } );

            /* 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; } );
                var isDisamb = ( p.pageprops && p.pageprops.disambiguation !== undefined ) ||
                    cats.some( function ( c ) { return c === 'Category:Disambiguation'; } );
                var isStub = cats.some( function ( c ) { return /[Ss]tub/.test( c ); } );
                return !isDisamb && !isStub;
            } );
            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, article.extract );
                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 ( err ) {
            /* Fallback: use static Lua-rendered slides */
            var staticSlides = hero.querySelectorAll( '.bsw-slide' );
            if ( staticSlides.length ) {
                /* Ensure first slide is active */
                staticSlides.forEach( function( s ) { s.classList.remove( 'bsw-active' ); } );
                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 ) + '" width="90" height="110">';
        } else {
            thumbHtml = '<img class="bsw-fa-thumb" src="https://en.battlestarwiki.org/resources/BSGWIKILOGO.png" alt="Battlestar Wiki" width="90" height="110">';
        }

        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, fr: FR_API, media: MEDIA_API };
        var base = apiMap[ wiki ];
        if ( !base ) return;

        var nsMap = {
            en:    '0',
            de:    '0',
            fr:    '0',
            media: '0|6'
        };

        apiGetFrom( base, {
            action:      'query',
            list:        'recentchanges',
            rcprop:      'title|timestamp|user|sizes',
            rcnamespace: nsMap[ wiki ] || '0',
            rclimit:     '10',
            rctype:      'edit|new'
        } ).then( function ( data ) {
            var changes = ( data.query.recentchanges || [] ).filter( function ( rc ) {
                /* Filter out main page and subpages */
                return rc.title !== 'Main Page' && rc.title.indexOf( '/' ) === -1;
            } );
            changes = changes.filter( function ( rc ) {
                return rc.title !== 'Main Page' && rc.title.indexOf( '/' ) === -1;
            } );
            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/',
                de:    'https://de.battlestarwiki.org/',
                fr:    'https://fr.battlestarwiki.ddns.net/wiki/',
                media: 'https://media.battlestarwiki.org/'
            }[ wiki ];

            var isCrosswiki = ( wiki !== 'all' && wiki !== 'en' );

            /* Deduplicate by title */
            var seen = {};
            changes = changes.filter( function ( rc ) {
                if ( seen[ rc.title ] ) return false;
                seen[ rc.title ] = true;
                return true;
            } );

            var html = '<ul class="bsw-recent">' + changes.map( function ( rc ) {
                var title      = rc.title;
                var displayTitle = title.replace( /^[^:]+:/, '' ); /* strip namespace prefix */
                var url    = baseUrl + encodeURIComponent( title.replace( / /g, '_' ) );
                var ts     = new Date( rc.timestamp );
                var ago    = timeAgo( ts );
                var cls    = isCrosswiki ? ' class="external"' : '';
                var target = isCrosswiki ? ' target="_blank"' : '';
                return '<li class="ri">' +
                    '<a href="' + esc( url ) + '"' + cls + target + '>' + esc( displayTitle ) + '</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';
        var hrs  = Math.floor( diff / 3600 );
        if ( diff < 86400 ) return hrs + ( hrs === 1 ? ' hr ago' : ' hrs ago' );
        var days = Math.floor( diff / 86400 );
        return days + ( days === 1 ? ' day ago' : ' days ago' );
    }

    /**
     * Newest article — pulls from Special:NewPages via API,
     * skips subpages, redirects, and disambiguation pages.
     */
    function initNewestArticle() {
        var inner = document.getElementById( 'bsw-newest-inner' );
        if ( !inner ) return;

        apiGet( {
            action:      'query',
            list:        'logevents',
            letype:      'create',
            lenamespace: '0',
            lelimit:     '20',
            leprop:      'title|timestamp'
        } ).then( function ( data ) {
            var entries = ( data.query.logevents || [] ).filter( function ( e ) {
                return e.title.indexOf( '/' ) === -1 && e.title !== 'Main Page';
            } );

            if ( !entries.length ) throw new Error( 'No entries' );

            var entry = entries[0];
            var title = entry.title;
            var url   = 'https://en.battlestarwiki.org/wiki/' + encodeURIComponent( title.replace( / /g, '_' ) );
            var ts    = new Date( entry.timestamp );
            var date  = ts.toLocaleDateString( 'en-US', { year: 'numeric', month: 'long', day: 'numeric' } );

            return apiGet( {
                action:  'query',
                titles:  title,
                prop:    'extracts|pageimages|pageprops',
                exintro: '1',
                exchars: '500',
                exsectionformat: 'plain',
                piprop:  'thumbnail',
                pithumbsize: '200',
                ppprop:  'disambiguation'
            } ).then( function ( d ) {
                var page = Object.values( d.query.pages || {} )[0] || {};
                /* Skip disambiguation pages, try next */
                if ( page.pageprops && page.pageprops.disambiguation !== undefined ) {
                    var next = entries[1];
                    if ( !next ) throw new Error( 'No suitable page' );
                    title = next.title;
                    url   = 'https://en.battlestarwiki.org/wiki/' + encodeURIComponent( title.replace( / /g, '_' ) );
                    date  = new Date( next.timestamp ).toLocaleDateString( 'en-US', { year: 'numeric', month: 'long', day: 'numeric' } );
                    return apiGet( {
                        action: 'query', titles: title,
                        prop: 'extracts|pageimages', exintro: '1', exchars: '300',
                        exsectionformat: 'plain', piprop: 'thumbnail', pithumbsize: '200'
                    } ).then( function ( d2 ) {
                        return { page: Object.values( d2.query.pages || {} )[0] || {}, title: title, url: url, date: date };
                    } );
                }
                return { page: page, title: title, url: url, date: date };
            } );

        } ).then( function ( result ) {
            var page    = result.page;
            var title   = result.title || page.title || '';
            var url     = result.url || ( 'https://en.battlestarwiki.org/wiki/' + encodeURIComponent( ( page.title || '' ).replace( / /g, '_' ) ) );
            var thumb   = page.thumbnail ? page.thumbnail.source : '';
            var extract = page.extract || '';

            var thumbHtml = thumb
                ? '<img src="' + esc( thumb ) + '" alt="' + esc( title ) + '" class="bsw-fa-thumb" width="90" height="110">'
                : '<img src="https://en.battlestarwiki.org/resources/BSGWIKILOGO.png" alt="Battlestar Wiki" class="bsw-fa-thumb" width="90" height="110">';

            /* Clean up extract — truncate at sentence boundary */
            var cleanExtract = extract.replace( /\s*\.\.\.\s*$/, '' );
            /* Try to truncate at last sentence end within ~400 chars */
            var stripped = cleanExtract.replace( /<[^>]+>/g, '' );
            if ( stripped.length > 400 ) {
                var lastPeriod = cleanExtract.lastIndexOf( '.', 500 );
                if ( lastPeriod > 200 ) {
                    cleanExtract = cleanExtract.slice( 0, lastPeriod + 1 );
                }
            }

            inner.innerHTML =
                thumbHtml +
                '<div>' +
                '<div class="bsw-fa-title"><a href="' + esc( url ) + '">' + esc( title ) + '</a></div>' +
                '<div class="bsw-newest-date">Created ' + esc( result.date ) + '</div>' +
                '<div class="bsw-fa-extract">' + cleanExtract + '</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>';

        } ).catch( function ( err ) {
            inner.innerHTML = '<div style="font-size:0.8125rem;color:var(--color-base--subtle);padding:0.5rem 0">Could not load newest article.</div>';
        } );
    }

    /* ── 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 ) {
            /* Skip cards that are JS-populated — they start with a loading spinner
               and will be filled in asynchronously */
            if ( card.querySelector( '.bsw-loading, .bsw-spinner' ) ) return;

            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 as fallback for inline onclick */
    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 );
    };

    function wireRcTabs() {
        var tabMap = { 'All': 'all', 'EN': 'en', 'DE': 'de', 'FR': 'fr', 'Media': 'media' };
        document.querySelectorAll( '.bsw-wtab' ).forEach( function ( tab ) {
            var wiki = tabMap[ tab.textContent.trim() ];
            if ( !wiki ) return;
            tab.addEventListener( 'click', function () {
                document.querySelectorAll( '.bsw-wtab' ).forEach( function ( t ) {
                    t.classList.remove( 'bsw-active' );
                } );
                tab.classList.add( 'bsw-active' );
                rcCurrentWiki = wiki;
                loadRecentChanges( wiki );
            } );
        } );
    }

    mw.hook( 'wikipage.content' ).add( function () {
        if ( !document.querySelector( '.bsw-main-page' ) ) return;
        initHero();
        initFeatured();
        initMediaFileCount();
        initNewestArticle();
        filterRedlinkCards();
        wireRcTabs();
        /* 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;
    } );

}() );