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

MediaWiki interface page
Joe Beaudoin Jr. (talk | contribs)
 
Joe Beaudoin Jr. (talk | contribs)
No edit summary
 
(12 intermediate revisions by the same user not shown)
Line 1: Line 1:
/* Portal image slider — MediaWiki:Common.js
/**
* BattlestarWiki — Portal page JavaScript
* MediaWiki:Common.portal.js  (or append to MediaWiki:Common.js)
  *
  *
  * Wires up any .portal-slider-wrapper on the page.
  * Handles per-portal widgets on any .portal-page:
  * The Lua module (Module:Portal|randomImage) renders the first image
  *   1. Image carousel
  * server-side; the JS fetches the full ImageList sub-page via the API
  *   2. Newest article (portal-scoped)
  * and pre-loads remaining images for the slider.
  *   3. Stats bar (live article count)
  *
  *
  * Falls back gracefully: if JS is disabled, the first random image
  * Self-contained — does not depend on helpers from the main page JS.
* is still shown (rendered server-side by Lua).
  */
  */
( function ( mw, $ ) {
 
( function () {
     'use strict';
     'use strict';


     /* ── Configuration ─────────────────────────────────── */
     /* ── API endpoints ──────────────────────────────────────────────── */
     var SLIDE_INTERVAL_MS = 5000;   // auto-advance interval
 
     var IMAGE_WIDTH_PX    = 600;   // thumbnail width requested from API
    var API      = 'https://en.battlestarwiki.org/w/api.php';
     var MEDIA_API = 'https://media.battlestarwiki.org/w/api.php';
 
    /* ── Shared helpers ─────────────────────────────────────────────── */
 
    function dailySeed() {
        var now = new Date();
        return Math.floor( Date.UTC(
            now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()
        ) / 86400000 );
    }
 
    function esc( s ) {
        return String( s || '' )
            .replace( /&/g, '&amp;' ).replace( /</g, '&lt;' )
            .replace( />/g, '&gt;' ).replace( /"/g, '&quot;' );
    }
 
    function apiGetFrom( baseUrl, params, crossOrigin ) {
        params.format = 'json';
        if ( crossOrigin ) { 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( 'HTTP ' + r.status );
                return r.json();
            } );
    }
 
    /* Same-origin: use relative URL, no origin param needed */
    function apiGet( params ) {
        return apiGetFrom( '/w/api.php', params, false );
     }
 
    /* Cross-origin: use absolute URL + origin=* for CORS */
    function apiGetCross( params ) {
        return apiGetFrom( MEDIA_API, params, true );
    }
 
    /* ── Portal entry point ───────────────────────────────────────── */
 
    mw.hook( 'wikipage.content' ).add( function () {
        var page = document.querySelector( '.portal-page' );
        if ( !page ) return;
 
        initPortalSlider( page );
        initPortalNewest( page );
        initPortalStats( page );
        initPortalOrphans( page );
        initPortalFeatured( page );
    } );
 
    /* ════════════════════════════════════════════════════════════════
      1. IMAGE CAROUSEL
      Reads Portal:NAME/ImageList for a curated file list, falls back
      to querying the category directly. Images are fetched via the
      media wiki's imageinfo API for thumbnail URLs.
      ════════════════════════════════════════════════════════════════ */


     /* ── Bootstrap: run after DOM ready ────────────────── */
     function initPortalSlider( page ) {
     mw.hook( 'wikipage.content' ).add( function ( $content ) {
        var hero = page.querySelector( '.portal-hero' );
         $content.find( '.portal-slider-wrapper' ).each( function () {
        if ( !hero ) return;
             initSlider( $( this ) );
 
        var category = hero.dataset.category || '';
        if ( !category ) return;
 
        /* Random seed per page load — different slides every visit */
        var seed = Math.floor( Math.random() * 2147483647 ) + 1;
 
        /* Query articles from the portal category on en.battlestarwiki.org.
          Mirror the Main Page bsw-hero pattern: article → pageimage → slide. */
        apiGet( {
            action:      'query',
            list:        'categorymembers',
            cmtitle:    'Category:' + category,
            cmtype:      'page',
            cmnamespace: '0',
            cmlimit:     '200'
        } ).then( function ( data ) {
            var members = ( data.query.categorymembers || [] );
            if ( !members.length ) throw new Error( 'empty category' );
 
            /* Daily-seeded shuffle, cap at 20 candidates to fetch images for */
            members = seededShuffle( members, seed ).slice( 0, 20 );
            var titles = members.map( function ( m ) { return m.title; } ).join( '|' );
 
            /* Fetch pageimage + extract + categories for each article */
            return apiGet( {
                action:          'query',
                titles:          titles,
                prop:            'pageimages|extracts|categories',
                piprop:          'thumbnail',
                pithumbsize:    '1200',
                pilimit:        '20',
                exintro:        '1',
                exchars:        '200',
                exsectionformat: 'plain',
                cllimit:        '20',
                formatversion:  '2'
            } ).then( function ( d ) {
                var slides = [];
                ( d.query.pages || [] ).forEach( function ( p ) {
                    /* Skip articles with no image */
                    if ( !p.thumbnail || !p.thumbnail.source ) return;
                    var extract = ( p.extract || '' ).replace( /<[^>]+>/g, '' ).trim();
                    if ( extract.length > 180 ) {
                        extract = extract.replace( /\s\S+$/, '' ) + '…';
                    }
                    /* Derive badge label from article categories */
                    var badge = portalSlideBadge( p.categories || [] );
                    slides.push( {
                        thumb:  p.thumbnail.source,
                        caption: extract || p.title,
                        title:  p.title,
                        link:    p.title,
                        badge:  badge
                    } );
                } );
                return slides;
            } );
         } ).then( function ( slides ) {
            if ( !slides.length ) throw new Error( 'No slides with images' );
            buildCarousel( hero, slides );
        } ).catch( function () { /* silent — hero stays dark */ } );
    }
 
    /** Map article categories to a human-readable slide badge label.
    *  Priority order matters — more specific checks first. */
    function portalSlideBadge( cats ) {
        var names = cats.map( function ( c ) {
             return c.title.replace( /^Category:/, '' ).toLowerCase();
         } );
         } );
    } );


    /* ── Per-slider initialisation ─────────────────────── */
        /* Exact/prefix matches in priority order */
    function initSlider( $wrapper ) {
        var checks = [
         var $nav      = $wrapper.find( '.portal-slider-nav' );
            /* Episodes first — most specific */
         var category  = $nav.data( 'category' );   // set by template
            [ 'episode guide',        'Episode'      ],
         var $prevBtn  = $nav.find( '.portal-slider-prev' );
            [ 'episodes by',          'Episode'      ],
        var $nextBtn  = $nav.find( '.portal-slider-next' );
            [ 'miniseries',            'Episode'      ],
        var $dots     = $nav.find( '.portal-slider-dots' );
 
         var $counter  = $nav.find( '.portal-slider-counter' );
            /* Ships / Craft */
            [ 'colonial craft',        'Ship'        ],
            [ 'cylon craft',          'Ship'        ],
            [ 'ships (',              'Ship'        ],
            [ 'battlestars (',         'Ship'        ],
            [ 'vipers (',              'Ship'        ],
            [ 'raptors',              'Ship'        ],
 
            /* Characters */
            [ 'main characters',      'Character'    ],
            [ 'recurring guest',      'Character'    ],
            [ 'one-shot characters',  'Character'    ],
            [ 'deceased characters',  'Character'    ],
            [ 'mentioned-only characters', 'Character' ],
            [ 'characters (',          'Character'    ],
 
            /* Cylons (before military) */
            [ 'cylons (',              'Cylon'        ],
            [ 'cylon military',        'Cylon'        ],
            [ 'cylon religion',        'Cylon'        ],
 
            /* Military */
            [ 'colonial military',    'Military'    ],
            [ 'colonial warriors',    'Military'    ],
            [ 'gaeta mutineers',      'Military'    ],
            [ 'fighter squadrons',    'Military'    ],
 
            /* Places / Locations */
            [ 'planets (',            'Planet'      ],
            [ 'moons (',              'Moon'         ],
            [ 'places (',              'Location'    ],
            [ 'galactica areas',      'Location'    ],
            [ 'places on ',            'Location'    ],
            [ 'basestar areas',        'Location'    ],
            [ 'twelve colonies',      'Location'    ],
 
            /* Society / Culture */
            [ 'colonial religion',    'Religion'    ],
            [ 'colonial society',      'Society'      ],
            [ 'colonial government',  'Government'  ],
            [ 'colonial history',      'History'      ],
            [ 'organizations (',      'Organization' ],
            [ 'ha'la'tha',          'Organization' ],
            [ 'soldiers of the one',  'Organization' ],
 
            /* Technology */
            [ 'technology (',          'Technology'  ],
            [ 'weapons (',            'Weapon'      ],
            [ 'drugs (',              'Technology],
 
            /* Production / Real world */
            [ 'cast (',                'Cast'        ],
            [ 'crew (',                'Crew'         ],
            [ 'directors (',          'Director'     ],
            [ 'writers (',            'Writer'       ],
            [ 'producers (',          'Producer'     ],
 
            /* Comics / Books */
            [ 'dynamite comics',      'Comic'        ],
            [ 'comics (',              'Comic'        ],
            [ 'novels',                'Novel'       ],
            [ 'books (',              'Book'         ],
 
            /* Terminology */
            [ 'terminology (',        'Term'        ],
            [ 'descriptive terms',    'Term'         ]
        ];


         // The server already rendered the first image inside $wrapper
         for ( var i = 0; i < checks.length; i++ ) {
        // as a plain <a><img></a> block (from wikitext [[File:...]]).
            for ( var j = 0; j < names.length; j++ ) {
         var $firstImg = $wrapper.find( 'a.image, img' ).first();
                if ( names[j].indexOf( checks[i][0] ) !== -1 ) {
                    return checks[i][1];
                }
            }
        }
         return ''; /* empty = fall back to seriesBadge in caller */
    }


         var slides  = [];  // { src, caption, pageTitle }
         /** Build and wire the carousel DOM — bsw-hero style */
    function buildCarousel( hero, slides ) {
         var current = 0;
         var current = 0;
         var timer   = null;
         var total   = slides.length;
         var $display;       // the <img> we'll swap
         var timer;
 
        /* Derive portal series label from data-category for the slide badge */
        var cat = ( hero.dataset.category || '' ).toUpperCase();
        var seriesBadge = cat || 'BSG';
 
        var bg      = hero.querySelector( '.portal-hero-bg' );
        var caption = hero.querySelector( '.portal-hero-caption' );
        var dotsEl  = hero.querySelector( '.portal-hero-dots' );
        var prevBtn = hero.querySelector( '.portal-hero-prev' );
        var nextBtn = hero.querySelector( '.portal-hero-next' );
 
        /* Clickable overlay covering the image area — sits above bg/overlay,
          below nav and footer so those remain independently clickable */
        var link = hero.querySelector( '.portal-hero-link' );
        if ( !link ) {
            link = document.createElement( 'a' );
            link.className = 'portal-hero-link';
            /* Insert after overlay, before content */
            var content = hero.querySelector( '.portal-hero-content' );
            hero.insertBefore( link, content );
        }


         /* ── Step 1: wrap the server-rendered image ──── */
         /* Build dot indicators */
         if ( $firstImg.length ) {
         if ( dotsEl ) {
             // Build a display container around it
             dotsEl.innerHTML = '';
             $display = $( '<img>' )
             slides.forEach( function ( _, i ) {
                 .addClass( 'portal-slider-image' )
                var dot = document.createElement( 'span' );
                 .attr( 'alt', '' )
                 dot.className = 'portal-hero-dot' + ( i === 0 ? ' is-active' : '' );
                 .css( { width: '100%', borderRadius: '6px',
                 dot.setAttribute( 'role', 'button' );
                        maxHeight: '260px', objectFit: 'cover' } );
                 dot.setAttribute( 'tabindex', '0' );
                dot.addEventListener( 'click', function () { goTo( i ); } );
                dotsEl.appendChild( dot );
            } );
        }


             var $caption = $( '<div>' ).addClass( 'portal-slider-caption' );
        function goTo( n ) {
             var $frame  = $( '<div>' ).addClass( 'portal-slider-frame' )
             current = ( n + total ) % total;
                .append( $display, $caption );
             var s = slides[ current ];


             // Replace whatever MW rendered with our controlled frame
             /* Set background image on the bg div — same as bsw-slide-bg */
             $firstImg.closest( 'a.image, p' ).first().replaceWith( $frame );
             if ( bg ) {
         } else {
                bg.style.backgroundImage = 'url(' + s.thumb + ')';
             // Placeholder already in DOM from Lua; just ensure nav shows
            }
             $prevBtn.hide();
            if ( caption ) {
             $nextBtn.hide();
                caption.innerHTML =
             return;
                    ( ( s.badge || seriesBadge ) ? '<div class="portal-hero-badge">✶ ' + esc( s.badge || seriesBadge ) + '</div>' : '' ) +
                    '<div class="portal-hero-title">' + esc( s.title ) + '</div>' +
                    ( s.caption && s.caption !== s.title
                        ? '<div class="portal-hero-extract">' + esc( s.caption ) + '</div>'
                        : '' );
            }
            /* Update the clickable overlay link */
            if ( link ) {
                if ( s.link ) {
                    link.href = '/' + encodeURIComponent( s.link.replace( / /g, '_' ) );
                    link.title = s.link;
                    link.style.display = 'block';
                } else {
                    /* No article link for this slide — disable the overlay */
                    link.removeAttribute( 'href' );
                    link.style.display = 'none';
                }
            }
 
            if ( dotsEl ) {
                dotsEl.querySelectorAll( '.portal-hero-dot' ).forEach( function ( d, i ) {
                    d.classList.toggle( 'is-active', i === current );
                } );
            }
            resetTimer();
         }
 
        function resetTimer() {
            clearInterval( timer );
            timer = setInterval( function () { goTo( current + 1 ); }, 6000 );
        }
 
        if ( prevBtn ) {
             prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } );
             prevBtn.addEventListener( 'keydown', function ( e ) {
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current - 1 ); }
            } );
        }
        if ( nextBtn ) {
             nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } );
            nextBtn.addEventListener( 'keydown', function ( e ) {
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current + 1 ); }
             } );
         }
         }


         /* ── Step 2: fetch ImageList sub-page ────────── */
        hero.addEventListener( 'mouseenter', function () { clearInterval( timer ); } );
        hero.addEventListener( 'mouseleave', resetTimer );
 
         /* Touch/swipe support */
        var touchX = null;
        hero.addEventListener( 'touchstart', function ( e ) {
            touchX = e.touches[0].clientX;
        }, { passive: true } );
        hero.addEventListener( 'touchend', function ( e ) {
            if ( touchX === null ) return;
            var dx = e.changedTouches[0].clientX - touchX;
            if ( Math.abs( dx ) > 40 ) goTo( dx < 0 ? current + 1 : current - 1 );
            touchX = null;
        }, { passive: true } );
 
        goTo( 0 );
    }
 
 
    /* ════════════════════════════════════════════════════════════════
      2. NEWEST ARTICLE (portal-scoped)
      Same logevents call as the main page newest widget, but filtered
      to the portal's category via a follow-up categorymembers check.
      ════════════════════════════════════════════════════════════════ */
 
    function initPortalNewest( page ) {
        var inner = page.querySelector( '.portal-newest-inner' );
        if ( !inner ) return;
 
        var category = inner.dataset.category || '';
         if ( !category ) return;
         if ( !category ) return;


         var listPage = mw.config.get( 'wgPageName' ) + '/ImageList';
         /* Fetch the 30 most recently created mainspace pages */
        apiGet( {
            action:      'query',
            list:        'logevents',
            letype:      'create',
            lenamespace: '0',
            lelimit:    '30',
            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' );


        mw.api = mw.api || new mw.Api();
            /* Check which of these titles are in the portal's category */
        ( new mw.Api() ).get( {
            var titles = entries.map( function ( e ) { return e.title; } );
             action: 'query',
            /* Build a timestamp map for quick lookup */
            titles: listPage,
            var tsMap = {};
            prop:   'revisions',
            entries.forEach( function ( e ) { tsMap[ e.title ] = e.timestamp; } );
            rvprop: 'content',
 
            rvslots: 'main',
            /* Check multiple RDM categories so articles in subcats are matched */
            formatversion: 2
             var catCandidates = [
        } ).then( function ( data ) {
                'Category:' + category,
            var pages = data.query && data.query.pages;
                'Category:Characters (RDM)',
             if ( !pages || !pages[0] ) return;
                'Category:Characters (TRS)',
                'Category:Episode Guide (RDM)',
                'Category:Colonial Military (RDM)',
                'Category:Colonial Craft (RDM)',
                'Category:Ships (RDM)',
                'Category:Places (RDM)',
                'Category:Technology (RDM)',
                'Category:Terminology (RDM)',
                'Category:Colonial Society (RDM)',
                'Category:Colonial History (RDM)',
                'Category:Cylons (RDM)'
             ];


             var page = pages[0];
             return apiGet( {
             if ( page.missing ) {
                action:      'query',
                 // No ImageList — fall back to random category query
                titles:      titles.slice( 0, 20 ).join( '|' ),
                 fetchRandomFromCategory( category );
                prop:        'categories',
                 return;
                clcategories: catCandidates.join( '|' ),
            }
                cllimit:      '20'
             } ).then( function ( d ) {
                 /* Find pages that ARE in any of the candidate categories */
                 var pages = Object.values( d.query.pages || {} );
                 var match = pages.find( function ( p ) {
                    return p.categories && p.categories.length > 0;
                } );


            var content = page.revisions &&
                /* If none match in the first 20, fall back to the single most recent */
                          page.revisions[0] &&
                var title = match ? match.title : entries[0].title;
                          page.revisions[0].slots &&
                var ts    = tsMap[ title ];
                          page.revisions[0].slots.main &&
                          page.revisions[0].slots.main.content;
            if ( !content ) return;


            var fileNames = [];
                return apiGet( {
            content.split( '\n' ).forEach( function ( line ) {
                    action:  'query',
                var trimmed = line.replace( /^\s+|\s+$/g, '' );
                    titles:  title,
                if ( trimmed ) {
                    prop:    'extracts|pageimages|pageprops',
                    // Strip leading "File:" if present
                    exintro: '1',
                    var fname = trimmed.replace( /^[Ff]ile:/, '' )
                    exchars: '400',
                                      .split( '|' )[0]
                    exsectionformat: 'plain',
                                      .replace( /^\s+|\s+$/g, '' );
                    piprop:  'thumbnail',
                     if ( fname ) fileNames.push( 'File:' + fname );
                    pithumbsize: '200',
                 }
                    ppprop:  'disambiguation'
                } ).then( function ( d2 ) {
                    var p = Object.values( d2.query.pages || {} )[0] || {};
                    /* Skip disambiguation, try plain most-recent as fallback */
                    if ( p.pageprops && p.pageprops.disambiguation !== undefined ) {
                        title = entries[0].title;
                        ts    = entries[0].timestamp;
                        return apiGet( {
                            action: 'query', titles: title,
                            prop: 'extracts|pageimages', exintro: '1', exchars: '400',
                            exsectionformat: 'plain', piprop: 'thumbnail', pithumbsize: '200'
                        } ).then( function ( d3 ) {
                            return { page: Object.values( d3.query.pages || {} )[0] || {}, ts: ts };
                        } );
                     }
                    return { page: p, ts: ts };
                 } );
             } );
             } );
        } ).then( function ( result ) {
            renderPortalNewest( inner, result.page, result.ts );
        } ).catch( function () {
            inner.innerHTML = '<div style="font-size:0.8125rem;color:var(--color-subtle,#888)">Could not load newest article.</div>';
        } );
    }


            if ( fileNames.length === 0 ) return;
    function renderPortalNewest( inner, page, ts ) {
        var title  = page.title || '';
        var url    = '/wiki/' + encodeURIComponent( title.replace( / /g, '_' ) );
        var thumb  = page.thumbnail ? page.thumbnail.source : '';
        var extract = ( page.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 300 );
        if ( extract.length === 300 ) extract = extract.replace( /\s\S+$/, '' ) + '\u2026';
        var date = ts ? new Date( ts ).toLocaleDateString( 'en-US', { year: 'numeric', month: 'long', day: 'numeric' } ) : '';


            // Shuffle using a date-seeded PRNG so order is stable all day
        var thumbHtml = thumb
             fileNames = seededShuffle( fileNames );
             ? '<img src="' + esc( thumb ) + '" alt="' + esc( title ) + '" class="bsw-fa-thumb" style="width:70px;height:52px;object-fit:cover;border-radius:4px;flex-shrink:0">'
             fetchThumbnails( fileNames );
             : '';


        inner.innerHTML =
            '<div style="display:flex;gap:10px;align-items:flex-start">' +
            thumbHtml +
            '<div>' +
            '<div class="portal-newest-title"><a href="' + esc( url ) + '">' + esc( title ) + '</a></div>' +
            ( date ? '<div class="portal-newest-meta">Created ' + esc( date ) + '</div>' : '' ) +
            '<div style="font-size:0.8em;color:var(--color-base--subtle,#666);margin-top:3px;line-height:1.5">' + esc( extract ) + '</div>' +
            '<div style="margin-top:4px"><a href="' + esc( url ) + '" style="font-size:0.75rem;color:var(--color-link,#3366cc)">Read more \u2192</a></div>' +
            '</div>' +
            '</div>';
    }
    /* ════════════════════════════════════════════════════════════════
      3. STATS BAR — live article count
      The stat_count span with data-category gets its number filled
      in from categoryinfo. All other stats are static wikitext.
      ════════════════════════════════════════════════════════════════ */
    function initPortalStats( page ) {
        var countEls = page.querySelectorAll( '.portal-stat-count[data-category]' );
        if ( !countEls.length ) return;
        /* Batch all stat categories into a single categoryinfo query */
        var titles = countEls.length
            ? Array.prototype.slice.call( countEls ).map( function ( el ) {
                return 'Category:' + el.dataset.category;
            } )
            : [];
        apiGet( {
            action:        'query',
            titles:        titles.join( '|' ),
            prop:          'categoryinfo',
            formatversion: '2'
        } ).then( function ( data ) {
            var pageMap = {};
            ( data.query.pages || [] ).forEach( function ( p ) {
                pageMap[ p.title ] = p;
            } );
            countEls.forEach( function ( el ) {
                var key = 'Category:' + el.dataset.category;
                var p  = pageMap[ key ];
                if ( p && p.categoryinfo ) {
                    el.textContent = ( p.categoryinfo.pages || 0 ).toLocaleString();
                } else {
                    el.textContent = '\u2014';
                }
            } );
         } ).catch( function () {
         } ).catch( function () {
             // Silent fail — first image still visible
             countEls.forEach( function ( el ) { el.textContent = '\u2014'; } );
         } );
         } );
    }
    /* ════════════════════════════════════════════════════════════════
      4. ORPHANED ARTICLES — dynamic, filtered to portal category
      Uses the MW API to find articles with no incoming links that
      are also members of the portal's stat_category.
      ════════════════════════════════════════════════════════════════ */
    function initPortalOrphans( page ) {
        var widget = page.querySelector( '#portal-widget-orphans[data-category]' );
        if ( !widget ) return;
        var inner    = widget.querySelector( '.portal-orphans-inner' );
        var category = widget.dataset.category;
        if ( !inner || !category ) return;
        inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Loading…</span>';


         /* ── Fetch thumbnails for a list of file titles ─ */
         /* Fetch global lonely pages, then filter to those in this portal's category.
         function fetchThumbnails( fileNames ) {
          list=querypage is available to all users; qplimit caps at 10 for non-sysops
             ( new mw.Api() ).get( {
          but we fetch 500 and let the server cap it — more matches for the category. */
                action:  'query',
         apiGet( {
                titles:   fileNames.slice( 0, 20 ).join( '|' ),
             action:  'query',
                prop:     'imageinfo',
            list:     'querypage',
                iiprop:   'url|extmetadata',
            qppage:   'Lonelypages',
                iiurlwidth: IMAGE_WIDTH_PX,
            qplimit: '500'
                formatversion: 2
        } ).then( function ( data ) {
            } ).then( function ( data ) {
            var lonely = ( ( data.query.querypage || {} ).results || [] )
                var pages = data.query && data.query.pages;
                .map( function ( r ) { return r.title; } );
                if ( !pages ) return;
            if ( !lonely.length ) return [];


                 pages.forEach( function ( page ) {
            /* Cross-reference against the portal's category in batches of 50 */
                    var ii = page.imageinfo && page.imageinfo[0];
            var batch = lonely.slice( 0, 50 ).join( '|' );
                     if ( !ii || !ii.thumburl ) return;
            return apiGet( {
                     var caption = '';
                 action:  'query',
                     if ( ii.extmetadata ) {
                titles:  batch,
                        caption = ( ii.extmetadata.ImageDescription &&
                prop:    'categories',
                                    ii.extmetadata.ImageDescription.value ) || '';
                clcategories: 'Category:' + category,
                        // Strip HTML tags
                cllimit: '1',
                         caption = caption.replace( /<[^>]+>/g, '' );
                formatversion: '2'
            } ).then( function ( d ) {
                var orphans = [];
                ( d.query.pages || [] ).forEach( function ( p ) {
                     if ( p.missing ) return;
                     /* categories present = this page IS in the portal category */
                     if ( p.categories && p.categories.length > 0 ) {
                         orphans.push( p.title );
                     }
                     }
                    slides.push( {
                        src:      ii.thumburl,
                        caption:  caption || page.title.replace( /^File:/, '' )
                                                        .replace( /\.[^.]+$/, '' ),
                        pageTitle: page.title
                    } );
                 } );
                 } );
                return orphans;
            } );
        } ).then( function ( orphans ) {
            if ( !orphans.length ) {
                inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">No orphaned articles found.</span>';
                return;
            }
            inner.innerHTML = orphans.map( function ( t ) {
                var url = '/' + encodeURIComponent( t.replace( / /g, '_' ) );
                return '<a href="' + esc( url ) + '" style="display:block;font-size:0.85em;padding:2px 0">' + esc( t ) + '</a>';
            } ).join( '' );
        } ).catch( function () {
            inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Could not load orphaned articles.</span>';
        } );
    }
    /* ════════════════════════════════════════════════════════════════
      5. FEATURED EPISODE SLIDER
      Reads a pipe-separated list of episode titles from the widget's
      data-episodes attribute (one per season), fetches extract +
      thumbnail for each, and builds a carousel.
      ════════════════════════════════════════════════════════════════ */
    function initPortalFeatured( page ) {
        var widget = page.querySelector( '#portal-widget-featured[data-episodes]' );
        if ( !widget ) return;
        var body    = widget.querySelector( '.portal-featured-body' );
        var dotsEl  = widget.querySelector( '.portal-featured-dots' );
        var prevBtn = widget.querySelector( '.portal-featured-prev' );
        var nextBtn = widget.querySelector( '.portal-featured-next' );
        var episodes = ( widget.dataset.episodes || '' ).split( '|' ).map( function ( s ) { return s.trim(); } ).filter( Boolean );
        if ( !episodes.length || !body ) return;


                if ( slides.length === 0 ) return;
        body.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Loading…</span>';


                // Set the first slide immediately
        apiGet( {
                showSlide( 0 );
            action:  'query',
                buildDots();
            titles:  episodes.join( '|' ),
                startAutoplay();
            prop:    'extracts|pageimages',
            exintro: '1',
            exchars: '500',
            exsectionformat: 'plain',
            piprop:  'thumbnail',
            pithumbsize: '400',
            formatversion: '2'
        } ).then( function ( data ) {
            var pageMap = {};
            ( data.query.pages || [] ).forEach( function ( p ) { pageMap[ p.title ] = p; } );


                $prevBtn.on( 'click', function () { advance( -1 ); } );
            /* Preserve the requested episode order */
                 $nextBtn.on( 'click', function () { advance( 1 ); } );
            var slides = episodes.map( function ( t ) {
                $wrapper.on( 'mouseenter', stopAutoplay )
                var p = pageMap[ t ] || {};
                        .on( 'mouseleave', startAutoplay );
                 return {
                    title:  t,
                    extract: ( p.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 400 ),
                    thumb:  p.thumbnail ? p.thumbnail.source : '',
                    url:    '/' + encodeURIComponent( t.replace( / /g, '_' ) )
                };
             } );
             } );
        }


        /* ── Fetch random images directly from category ─ */
             buildFeaturedCarousel( body, dotsEl, prevBtn, nextBtn, slides );
        function fetchRandomFromCategory( cat ) {
        } ).catch( function () {
             ( new mw.Api() ).get( {
            body.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Could not load featured episodes.</span>';
                action:    'query',
        } );
                list:      'categorymembers',
    }
                cmtitle:    'Category:' + cat,
                cmtype:    'file',
                cmlimit:    20,
                cmsort:    'sortkey',
                formatversion: 2
            } ).then( function ( data ) {
                var members = data.query && data.query.categorymembers;
                if ( !members || members.length === 0 ) return;


                var fileNames = members.map( function ( m ) { return m.title; } );
    function buildFeaturedCarousel( body, dotsEl, prevBtn, nextBtn, slides ) {
                fileNames = seededShuffle( fileNames );
        var current = 0;
                fetchThumbnails( fileNames );
        var total  = slides.length;
            } );
        }


        /* ── Slide display ──────────────────────────────── */
         function render( n ) {
         function showSlide( idx ) {
             current = ( n + total ) % total;
             current = ( idx + slides.length ) % slides.length;
             var s = slides[ current ];
             var slide = slides[current];


             $display.attr( 'src', slide.src ).attr( 'alt', slide.caption );
             var thumbHtml = s.thumb
            $wrapper.find( '.portal-slider-caption' )
                ? '<img src="' + esc( s.thumb ) + '" alt="' + esc( s.title ) + '" style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:var(--border-radius-medium,6px);display:block;margin-bottom:10px">'
                    .text( slide.caption );
                : '';


             $dots.find( '.portal-slider-dot' ).removeClass( 'is-active' )
             var extract = s.extract;
                .eq( current ).addClass( 'is-active' );
            if ( extract.length >= 400 ) extract = extract.replace( /\s\S+$/, '' ) + '';


             $counter.text( ( current + 1 ) + ' / ' + slides.length );
             body.innerHTML =
        }
                '<a href="' + esc( s.url ) + '" style="text-decoration:none;display:block">' + thumbHtml + '</a>' +
                '<div style="font-weight:600;margin-bottom:4px"><a href="' + esc( s.url ) + '">' + esc( s.title ) + '</a></div>' +
                '<div style="font-size:0.825em;color:var(--color-base--subtle,#888);line-height:1.5">' + esc( extract ) + '</div>' +
                '<div style="margin-top:6px"><a href="' + esc( s.url ) + '" style="font-size:0.75rem">Read more →</a></div>';


        function advance( dir ) {
            if ( dotsEl ) {
            stopAutoplay();
                dotsEl.querySelectorAll( '.portal-featured-dot' ).forEach( function ( d, i ) {
            showSlide( current + dir );
                    d.classList.toggle( 'is-active', i === current );
            startAutoplay();
                } );
            }
         }
         }


         /* ── Dots ───────────────────────────────────────── */
         /* Build dots */
         function buildDots() {
         if ( dotsEl ) {
             $dots.empty();
             dotsEl.innerHTML = '';
             for ( var i = 0; i < slides.length; i++ ) {
             slides.forEach( function ( _, i ) {
                 $( '<span>' ).addClass( 'portal-slider-dot' )
                 var dot = document.createElement( 'span' );
                    .toggleClass( 'is-active', i === 0 )
                dot.className = 'portal-featured-dot' + ( i === 0 ? ' is-active' : '' );
                    .data( 'idx', i )
                dot.setAttribute( 'role', 'button' );
                    .on( 'click', function () { showSlide( $( this ).data( 'idx' ) ); } )
                dot.setAttribute( 'tabindex', '0' );
                    .appendTo( $dots );
                dot.title = 'Season ' + ( i + 1 );
             }
                dot.addEventListener( 'click', function () { render( i ); } );
                dotsEl.appendChild( dot );
             } );
         }
         }


         /* ── Auto-play ──────────────────────────────────── */
         if ( prevBtn ) {
        function startAutoplay() {
             prevBtn.addEventListener( 'click', function () { render( current - 1 ); } );
             stopAutoplay();
             prevBtn.addEventListener( 'keydown', function ( e ) {
             timer = setInterval( function () { advance( 1 ); }, SLIDE_INTERVAL_MS );
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); render( current - 1 ); }
            } );
         }
         }
         function stopAutoplay() {
         if ( nextBtn ) {
             if ( timer ) { clearInterval( timer ); timer = null; }
            nextBtn.addEventListener( 'click', function () { render( current + 1 ); } );
             nextBtn.addEventListener( 'keydown', function ( e ) {
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); render( current + 1 ); }
            } );
         }
         }
        render( 0 );
     }
     }


    /* ── Seeded shuffle (date-based, stable all day) ──── */
        /* ── Seeded shuffle (stable within a calendar day) ────────────── */
     function seededShuffle( arr ) {
     function seededShuffle( arr, seed ) {
        var date  = new Date();
        var seed   = date.getFullYear() * 10000 +
                    ( date.getMonth() + 1 ) * 100 +
                    date.getDate();
        var state  = seed;
 
        function rand() {
            // Xorshift32
            state ^= state << 13;
            state ^= state >> 17;
            state ^= state << 5;
            return ( state >>> 0 ) / 0xFFFFFFFF;
        }
 
         var a = arr.slice();
         var a = arr.slice();
        var s = seed;
         for ( var i = a.length - 1; i > 0; i-- ) {
         for ( var i = a.length - 1; i > 0; i-- ) {
             var j = Math.floor( rand() * ( i + 1 ) );
            /* Xorshift32 */
            s ^= s << 13;
            s ^= s >> 17;
            s ^= s << 5;
             var j = ( ( s >>> 0 ) % ( i + 1 ) );
             var t = a[i]; a[i] = a[j]; a[j] = t;
             var t = a[i]; a[i] = a[j]; a[j] = t;
         }
         }
Line 248: Line 704:
     }
     }


}( mediaWiki, jQuery ) );
}() );

Latest revision as of 02:55, 14 April 2026

/**
 * BattlestarWiki — Portal page JavaScript
 * MediaWiki:Common.portal.js  (or append to MediaWiki:Common.js)
 *
 * Handles per-portal widgets on any .portal-page:
 *   1. Image carousel
 *   2. Newest article (portal-scoped)
 *   3. Stats bar (live article count)
 *
 * Self-contained — does not depend on helpers from the main page JS.
 */

( function () {
    'use strict';

    /* ── API endpoints ──────────────────────────────────────────────── */

    var API       = 'https://en.battlestarwiki.org/w/api.php';
    var MEDIA_API = 'https://media.battlestarwiki.org/w/api.php';

    /* ── Shared helpers ─────────────────────────────────────────────── */

    function dailySeed() {
        var now = new Date();
        return Math.floor( Date.UTC(
            now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()
        ) / 86400000 );
    }

    function esc( s ) {
        return String( s || '' )
            .replace( /&/g, '&amp;' ).replace( /</g, '&lt;' )
            .replace( />/g, '&gt;' ).replace( /"/g, '&quot;' );
    }

    function apiGetFrom( baseUrl, params, crossOrigin ) {
        params.format = 'json';
        if ( crossOrigin ) { 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( 'HTTP ' + r.status );
                return r.json();
            } );
    }

    /* Same-origin: use relative URL, no origin param needed */
    function apiGet( params ) {
        return apiGetFrom( '/w/api.php', params, false );
    }

    /* Cross-origin: use absolute URL + origin=* for CORS */
    function apiGetCross( params ) {
        return apiGetFrom( MEDIA_API, params, true );
    }

    /* ── Portal entry point ───────────────────────────────────────── */

    mw.hook( 'wikipage.content' ).add( function () {
        var page = document.querySelector( '.portal-page' );
        if ( !page ) return;

        initPortalSlider( page );
        initPortalNewest( page );
        initPortalStats( page );
        initPortalOrphans( page );
        initPortalFeatured( page );
    } );

    /* ════════════════════════════════════════════════════════════════
       1. IMAGE CAROUSEL
       Reads Portal:NAME/ImageList for a curated file list, falls back
       to querying the category directly. Images are fetched via the
       media wiki's imageinfo API for thumbnail URLs.
       ════════════════════════════════════════════════════════════════ */

    function initPortalSlider( page ) {
        var hero = page.querySelector( '.portal-hero' );
        if ( !hero ) return;

        var category = hero.dataset.category || '';
        if ( !category ) return;

        /* Random seed per page load — different slides every visit */
        var seed = Math.floor( Math.random() * 2147483647 ) + 1;

        /* Query articles from the portal category on en.battlestarwiki.org.
           Mirror the Main Page bsw-hero pattern: article → pageimage → slide. */
        apiGet( {
            action:      'query',
            list:        'categorymembers',
            cmtitle:     'Category:' + category,
            cmtype:      'page',
            cmnamespace: '0',
            cmlimit:     '200'
        } ).then( function ( data ) {
            var members = ( data.query.categorymembers || [] );
            if ( !members.length ) throw new Error( 'empty category' );

            /* Daily-seeded shuffle, cap at 20 candidates to fetch images for */
            members = seededShuffle( members, seed ).slice( 0, 20 );
            var titles = members.map( function ( m ) { return m.title; } ).join( '|' );

            /* Fetch pageimage + extract + categories for each article */
            return apiGet( {
                action:          'query',
                titles:          titles,
                prop:            'pageimages|extracts|categories',
                piprop:          'thumbnail',
                pithumbsize:     '1200',
                pilimit:         '20',
                exintro:         '1',
                exchars:         '200',
                exsectionformat: 'plain',
                cllimit:         '20',
                formatversion:   '2'
            } ).then( function ( d ) {
                var slides = [];
                ( d.query.pages || [] ).forEach( function ( p ) {
                    /* Skip articles with no image */
                    if ( !p.thumbnail || !p.thumbnail.source ) return;
                    var extract = ( p.extract || '' ).replace( /<[^>]+>/g, '' ).trim();
                    if ( extract.length > 180 ) {
                        extract = extract.replace( /\s\S+$/, '' ) + '…';
                    }
                    /* Derive badge label from article categories */
                    var badge = portalSlideBadge( p.categories || [] );
                    slides.push( {
                        thumb:   p.thumbnail.source,
                        caption: extract || p.title,
                        title:   p.title,
                        link:    p.title,
                        badge:   badge
                    } );
                } );
                return slides;
            } );
        } ).then( function ( slides ) {
            if ( !slides.length ) throw new Error( 'No slides with images' );
            buildCarousel( hero, slides );
        } ).catch( function () { /* silent — hero stays dark */ } );
    }

    /** Map article categories to a human-readable slide badge label.
     *  Priority order matters — more specific checks first. */
    function portalSlideBadge( cats ) {
        var names = cats.map( function ( c ) {
            return c.title.replace( /^Category:/, '' ).toLowerCase();
        } );

        /* Exact/prefix matches in priority order */
        var checks = [
            /* Episodes first — most specific */
            [ 'episode guide',         'Episode'      ],
            [ 'episodes by',           'Episode'      ],
            [ 'miniseries',            'Episode'      ],

            /* Ships / Craft */
            [ 'colonial craft',        'Ship'         ],
            [ 'cylon craft',           'Ship'         ],
            [ 'ships (',               'Ship'         ],
            [ 'battlestars (',         'Ship'         ],
            [ 'vipers (',              'Ship'         ],
            [ 'raptors',               'Ship'         ],

            /* Characters */
            [ 'main characters',       'Character'    ],
            [ 'recurring guest',       'Character'    ],
            [ 'one-shot characters',   'Character'    ],
            [ 'deceased characters',   'Character'    ],
            [ 'mentioned-only characters', 'Character' ],
            [ 'characters (',          'Character'    ],

            /* Cylons (before military) */
            [ 'cylons (',              'Cylon'        ],
            [ 'cylon military',        'Cylon'        ],
            [ 'cylon religion',        'Cylon'        ],

            /* Military */
            [ 'colonial military',     'Military'     ],
            [ 'colonial warriors',     'Military'     ],
            [ 'gaeta mutineers',       'Military'     ],
            [ 'fighter squadrons',     'Military'     ],

            /* Places / Locations */
            [ 'planets (',             'Planet'       ],
            [ 'moons (',               'Moon'         ],
            [ 'places (',              'Location'     ],
            [ 'galactica areas',       'Location'     ],
            [ 'places on ',            'Location'     ],
            [ 'basestar areas',        'Location'     ],
            [ 'twelve colonies',       'Location'     ],

            /* Society / Culture */
            [ 'colonial religion',     'Religion'     ],
            [ 'colonial society',      'Society'      ],
            [ 'colonial government',   'Government'   ],
            [ 'colonial history',      'History'      ],
            [ 'organizations (',       'Organization' ],
            [ 'ha'la'tha',           'Organization' ],
            [ 'soldiers of the one',   'Organization' ],

            /* Technology */
            [ 'technology (',          'Technology'   ],
            [ 'weapons (',             'Weapon'       ],
            [ 'drugs (',               'Technology'   ],

            /* Production / Real world */
            [ 'cast (',                'Cast'         ],
            [ 'crew (',                'Crew'         ],
            [ 'directors (',           'Director'     ],
            [ 'writers (',             'Writer'       ],
            [ 'producers (',           'Producer'     ],

            /* Comics / Books */
            [ 'dynamite comics',       'Comic'        ],
            [ 'comics (',              'Comic'        ],
            [ 'novels',                'Novel'        ],
            [ 'books (',               'Book'         ],

            /* Terminology */
            [ 'terminology (',         'Term'         ],
            [ 'descriptive terms',     'Term'         ]
        ];

        for ( var i = 0; i < checks.length; i++ ) {
            for ( var j = 0; j < names.length; j++ ) {
                if ( names[j].indexOf( checks[i][0] ) !== -1 ) {
                    return checks[i][1];
                }
            }
        }
        return '';  /* empty = fall back to seriesBadge in caller */
    }

        /** Build and wire the carousel DOM — bsw-hero style */
    function buildCarousel( hero, slides ) {
        var current = 0;
        var total   = slides.length;
        var timer;

        /* Derive portal series label from data-category for the slide badge */
        var cat = ( hero.dataset.category || '' ).toUpperCase();
        var seriesBadge = cat || 'BSG';

        var bg      = hero.querySelector( '.portal-hero-bg' );
        var caption = hero.querySelector( '.portal-hero-caption' );
        var dotsEl  = hero.querySelector( '.portal-hero-dots' );
        var prevBtn = hero.querySelector( '.portal-hero-prev' );
        var nextBtn = hero.querySelector( '.portal-hero-next' );

        /* Clickable overlay covering the image area — sits above bg/overlay,
           below nav and footer so those remain independently clickable */
        var link = hero.querySelector( '.portal-hero-link' );
        if ( !link ) {
            link = document.createElement( 'a' );
            link.className = 'portal-hero-link';
            /* Insert after overlay, before content */
            var content = hero.querySelector( '.portal-hero-content' );
            hero.insertBefore( link, content );
        }

        /* Build dot indicators */
        if ( dotsEl ) {
            dotsEl.innerHTML = '';
            slides.forEach( function ( _, i ) {
                var dot = document.createElement( 'span' );
                dot.className = 'portal-hero-dot' + ( i === 0 ? ' is-active' : '' );
                dot.setAttribute( 'role', 'button' );
                dot.setAttribute( 'tabindex', '0' );
                dot.addEventListener( 'click', function () { goTo( i ); } );
                dotsEl.appendChild( dot );
            } );
        }

        function goTo( n ) {
            current = ( n + total ) % total;
            var s = slides[ current ];

            /* Set background image on the bg div — same as bsw-slide-bg */
            if ( bg ) {
                bg.style.backgroundImage = 'url(' + s.thumb + ')';
            }
            if ( caption ) {
                caption.innerHTML =
                    ( ( s.badge || seriesBadge ) ? '<div class="portal-hero-badge">✶ ' + esc( s.badge || seriesBadge ) + '</div>' : '' ) +
                    '<div class="portal-hero-title">' + esc( s.title ) + '</div>' +
                    ( s.caption && s.caption !== s.title
                        ? '<div class="portal-hero-extract">' + esc( s.caption ) + '</div>'
                        : '' );
            }
            /* Update the clickable overlay link */
            if ( link ) {
                if ( s.link ) {
                    link.href = '/' + encodeURIComponent( s.link.replace( / /g, '_' ) );
                    link.title = s.link;
                    link.style.display = 'block';
                } else {
                    /* No article link for this slide — disable the overlay */
                    link.removeAttribute( 'href' );
                    link.style.display = 'none';
                }
            }

            if ( dotsEl ) {
                dotsEl.querySelectorAll( '.portal-hero-dot' ).forEach( function ( d, i ) {
                    d.classList.toggle( 'is-active', i === current );
                } );
            }
            resetTimer();
        }

        function resetTimer() {
            clearInterval( timer );
            timer = setInterval( function () { goTo( current + 1 ); }, 6000 );
        }

        if ( prevBtn ) {
            prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } );
            prevBtn.addEventListener( 'keydown', function ( e ) {
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current - 1 ); }
            } );
        }
        if ( nextBtn ) {
            nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } );
            nextBtn.addEventListener( 'keydown', function ( e ) {
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current + 1 ); }
            } );
        }

        hero.addEventListener( 'mouseenter', function () { clearInterval( timer ); } );
        hero.addEventListener( 'mouseleave', resetTimer );

        /* Touch/swipe support */
        var touchX = null;
        hero.addEventListener( 'touchstart', function ( e ) {
            touchX = e.touches[0].clientX;
        }, { passive: true } );
        hero.addEventListener( 'touchend', function ( e ) {
            if ( touchX === null ) return;
            var dx = e.changedTouches[0].clientX - touchX;
            if ( Math.abs( dx ) > 40 ) goTo( dx < 0 ? current + 1 : current - 1 );
            touchX = null;
        }, { passive: true } );

        goTo( 0 );
    }


    /* ════════════════════════════════════════════════════════════════
       2. NEWEST ARTICLE (portal-scoped)
       Same logevents call as the main page newest widget, but filtered
       to the portal's category via a follow-up categorymembers check.
       ════════════════════════════════════════════════════════════════ */

    function initPortalNewest( page ) {
        var inner = page.querySelector( '.portal-newest-inner' );
        if ( !inner ) return;

        var category = inner.dataset.category || '';
        if ( !category ) return;

        /* Fetch the 30 most recently created mainspace pages */
        apiGet( {
            action:      'query',
            list:        'logevents',
            letype:      'create',
            lenamespace: '0',
            lelimit:     '30',
            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' );

            /* Check which of these titles are in the portal's category */
            var titles = entries.map( function ( e ) { return e.title; } );
            /* Build a timestamp map for quick lookup */
            var tsMap = {};
            entries.forEach( function ( e ) { tsMap[ e.title ] = e.timestamp; } );

            /* Check multiple RDM categories so articles in subcats are matched */
            var catCandidates = [
                'Category:' + category,
                'Category:Characters (RDM)',
                'Category:Characters (TRS)',
                'Category:Episode Guide (RDM)',
                'Category:Colonial Military (RDM)',
                'Category:Colonial Craft (RDM)',
                'Category:Ships (RDM)',
                'Category:Places (RDM)',
                'Category:Technology (RDM)',
                'Category:Terminology (RDM)',
                'Category:Colonial Society (RDM)',
                'Category:Colonial History (RDM)',
                'Category:Cylons (RDM)'
            ];

            return apiGet( {
                action:       'query',
                titles:       titles.slice( 0, 20 ).join( '|' ),
                prop:         'categories',
                clcategories: catCandidates.join( '|' ),
                cllimit:      '20'
            } ).then( function ( d ) {
                /* Find pages that ARE in any of the candidate categories */
                var pages = Object.values( d.query.pages || {} );
                var match = pages.find( function ( p ) {
                    return p.categories && p.categories.length > 0;
                } );

                /* If none match in the first 20, fall back to the single most recent */
                var title = match ? match.title : entries[0].title;
                var ts    = tsMap[ title ];

                return apiGet( {
                    action:  'query',
                    titles:  title,
                    prop:    'extracts|pageimages|pageprops',
                    exintro: '1',
                    exchars: '400',
                    exsectionformat: 'plain',
                    piprop:  'thumbnail',
                    pithumbsize: '200',
                    ppprop:  'disambiguation'
                } ).then( function ( d2 ) {
                    var p = Object.values( d2.query.pages || {} )[0] || {};
                    /* Skip disambiguation, try plain most-recent as fallback */
                    if ( p.pageprops && p.pageprops.disambiguation !== undefined ) {
                        title = entries[0].title;
                        ts    = entries[0].timestamp;
                        return apiGet( {
                            action: 'query', titles: title,
                            prop: 'extracts|pageimages', exintro: '1', exchars: '400',
                            exsectionformat: 'plain', piprop: 'thumbnail', pithumbsize: '200'
                        } ).then( function ( d3 ) {
                            return { page: Object.values( d3.query.pages || {} )[0] || {}, ts: ts };
                        } );
                    }
                    return { page: p, ts: ts };
                } );
            } );
        } ).then( function ( result ) {
            renderPortalNewest( inner, result.page, result.ts );
        } ).catch( function () {
            inner.innerHTML = '<div style="font-size:0.8125rem;color:var(--color-subtle,#888)">Could not load newest article.</div>';
        } );
    }

    function renderPortalNewest( inner, page, ts ) {
        var title   = page.title || '';
        var url     = '/wiki/' + encodeURIComponent( title.replace( / /g, '_' ) );
        var thumb   = page.thumbnail ? page.thumbnail.source : '';
        var extract = ( page.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 300 );
        if ( extract.length === 300 ) extract = extract.replace( /\s\S+$/, '' ) + '\u2026';
        var date = ts ? new Date( ts ).toLocaleDateString( 'en-US', { year: 'numeric', month: 'long', day: 'numeric' } ) : '';

        var thumbHtml = thumb
            ? '<img src="' + esc( thumb ) + '" alt="' + esc( title ) + '" class="bsw-fa-thumb" style="width:70px;height:52px;object-fit:cover;border-radius:4px;flex-shrink:0">'
            : '';

        inner.innerHTML =
            '<div style="display:flex;gap:10px;align-items:flex-start">' +
            thumbHtml +
            '<div>' +
            '<div class="portal-newest-title"><a href="' + esc( url ) + '">' + esc( title ) + '</a></div>' +
            ( date ? '<div class="portal-newest-meta">Created ' + esc( date ) + '</div>' : '' ) +
            '<div style="font-size:0.8em;color:var(--color-base--subtle,#666);margin-top:3px;line-height:1.5">' + esc( extract ) + '</div>' +
            '<div style="margin-top:4px"><a href="' + esc( url ) + '" style="font-size:0.75rem;color:var(--color-link,#3366cc)">Read more \u2192</a></div>' +
            '</div>' +
            '</div>';
    }

    /* ════════════════════════════════════════════════════════════════
       3. STATS BAR — live article count
       The stat_count span with data-category gets its number filled
       in from categoryinfo. All other stats are static wikitext.
       ════════════════════════════════════════════════════════════════ */

    function initPortalStats( page ) {
        var countEls = page.querySelectorAll( '.portal-stat-count[data-category]' );
        if ( !countEls.length ) return;

        /* Batch all stat categories into a single categoryinfo query */
        var titles = countEls.length
            ? Array.prototype.slice.call( countEls ).map( function ( el ) {
                return 'Category:' + el.dataset.category;
            } )
            : [];

        apiGet( {
            action:        'query',
            titles:        titles.join( '|' ),
            prop:          'categoryinfo',
            formatversion: '2'
        } ).then( function ( data ) {
            var pageMap = {};
            ( data.query.pages || [] ).forEach( function ( p ) {
                pageMap[ p.title ] = p;
            } );
            countEls.forEach( function ( el ) {
                var key = 'Category:' + el.dataset.category;
                var p   = pageMap[ key ];
                if ( p && p.categoryinfo ) {
                    el.textContent = ( p.categoryinfo.pages || 0 ).toLocaleString();
                } else {
                    el.textContent = '\u2014';
                }
            } );
        } ).catch( function () {
            countEls.forEach( function ( el ) { el.textContent = '\u2014'; } );
        } );
    }

    /* ════════════════════════════════════════════════════════════════
       4. ORPHANED ARTICLES — dynamic, filtered to portal category
       Uses the MW API to find articles with no incoming links that
       are also members of the portal's stat_category.
       ════════════════════════════════════════════════════════════════ */

    function initPortalOrphans( page ) {
        var widget = page.querySelector( '#portal-widget-orphans[data-category]' );
        if ( !widget ) return;

        var inner    = widget.querySelector( '.portal-orphans-inner' );
        var category = widget.dataset.category;
        if ( !inner || !category ) return;

        inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Loading…</span>';

        /* Fetch global lonely pages, then filter to those in this portal's category.
           list=querypage is available to all users; qplimit caps at 10 for non-sysops
           but we fetch 500 and let the server cap it — more matches for the category. */
        apiGet( {
            action:   'query',
            list:     'querypage',
            qppage:   'Lonelypages',
            qplimit:  '500'
        } ).then( function ( data ) {
            var lonely = ( ( data.query.querypage || {} ).results || [] )
                .map( function ( r ) { return r.title; } );
            if ( !lonely.length ) return [];

            /* Cross-reference against the portal's category in batches of 50 */
            var batch = lonely.slice( 0, 50 ).join( '|' );
            return apiGet( {
                action:  'query',
                titles:  batch,
                prop:    'categories',
                clcategories: 'Category:' + category,
                cllimit: '1',
                formatversion: '2'
            } ).then( function ( d ) {
                var orphans = [];
                ( d.query.pages || [] ).forEach( function ( p ) {
                    if ( p.missing ) return;
                    /* categories present = this page IS in the portal category */
                    if ( p.categories && p.categories.length > 0 ) {
                        orphans.push( p.title );
                    }
                } );
                return orphans;
            } );
        } ).then( function ( orphans ) {
            if ( !orphans.length ) {
                inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">No orphaned articles found.</span>';
                return;
            }
            inner.innerHTML = orphans.map( function ( t ) {
                var url = '/' + encodeURIComponent( t.replace( / /g, '_' ) );
                return '<a href="' + esc( url ) + '" style="display:block;font-size:0.85em;padding:2px 0">' + esc( t ) + '</a>';
            } ).join( '' );
        } ).catch( function () {
            inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Could not load orphaned articles.</span>';
        } );
    }

    /* ════════════════════════════════════════════════════════════════
       5. FEATURED EPISODE SLIDER
       Reads a pipe-separated list of episode titles from the widget's
       data-episodes attribute (one per season), fetches extract +
       thumbnail for each, and builds a carousel.
       ════════════════════════════════════════════════════════════════ */

    function initPortalFeatured( page ) {
        var widget = page.querySelector( '#portal-widget-featured[data-episodes]' );
        if ( !widget ) return;

        var body    = widget.querySelector( '.portal-featured-body' );
        var dotsEl  = widget.querySelector( '.portal-featured-dots' );
        var prevBtn = widget.querySelector( '.portal-featured-prev' );
        var nextBtn = widget.querySelector( '.portal-featured-next' );

        var episodes = ( widget.dataset.episodes || '' ).split( '|' ).map( function ( s ) { return s.trim(); } ).filter( Boolean );
        if ( !episodes.length || !body ) return;

        body.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Loading…</span>';

        apiGet( {
            action:  'query',
            titles:  episodes.join( '|' ),
            prop:    'extracts|pageimages',
            exintro: '1',
            exchars: '500',
            exsectionformat: 'plain',
            piprop:  'thumbnail',
            pithumbsize: '400',
            formatversion: '2'
        } ).then( function ( data ) {
            var pageMap = {};
            ( data.query.pages || [] ).forEach( function ( p ) { pageMap[ p.title ] = p; } );

            /* Preserve the requested episode order */
            var slides = episodes.map( function ( t ) {
                var p = pageMap[ t ] || {};
                return {
                    title:   t,
                    extract: ( p.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 400 ),
                    thumb:   p.thumbnail ? p.thumbnail.source : '',
                    url:     '/' + encodeURIComponent( t.replace( / /g, '_' ) )
                };
            } );

            buildFeaturedCarousel( body, dotsEl, prevBtn, nextBtn, slides );
        } ).catch( function () {
            body.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Could not load featured episodes.</span>';
        } );
    }

    function buildFeaturedCarousel( body, dotsEl, prevBtn, nextBtn, slides ) {
        var current = 0;
        var total   = slides.length;

        function render( n ) {
            current = ( n + total ) % total;
            var s = slides[ current ];

            var thumbHtml = s.thumb
                ? '<img src="' + esc( s.thumb ) + '" alt="' + esc( s.title ) + '" style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:var(--border-radius-medium,6px);display:block;margin-bottom:10px">'
                : '';

            var extract = s.extract;
            if ( extract.length >= 400 ) extract = extract.replace( /\s\S+$/, '' ) + '…';

            body.innerHTML =
                '<a href="' + esc( s.url ) + '" style="text-decoration:none;display:block">' + thumbHtml + '</a>' +
                '<div style="font-weight:600;margin-bottom:4px"><a href="' + esc( s.url ) + '">' + esc( s.title ) + '</a></div>' +
                '<div style="font-size:0.825em;color:var(--color-base--subtle,#888);line-height:1.5">' + esc( extract ) + '</div>' +
                '<div style="margin-top:6px"><a href="' + esc( s.url ) + '" style="font-size:0.75rem">Read more →</a></div>';

            if ( dotsEl ) {
                dotsEl.querySelectorAll( '.portal-featured-dot' ).forEach( function ( d, i ) {
                    d.classList.toggle( 'is-active', i === current );
                } );
            }
        }

        /* Build dots */
        if ( dotsEl ) {
            dotsEl.innerHTML = '';
            slides.forEach( function ( _, i ) {
                var dot = document.createElement( 'span' );
                dot.className = 'portal-featured-dot' + ( i === 0 ? ' is-active' : '' );
                dot.setAttribute( 'role', 'button' );
                dot.setAttribute( 'tabindex', '0' );
                dot.title = 'Season ' + ( i + 1 );
                dot.addEventListener( 'click', function () { render( i ); } );
                dotsEl.appendChild( dot );
            } );
        }

        if ( prevBtn ) {
            prevBtn.addEventListener( 'click', function () { render( current - 1 ); } );
            prevBtn.addEventListener( 'keydown', function ( e ) {
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); render( current - 1 ); }
            } );
        }
        if ( nextBtn ) {
            nextBtn.addEventListener( 'click', function () { render( current + 1 ); } );
            nextBtn.addEventListener( 'keydown', function ( e ) {
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); render( current + 1 ); }
            } );
        }

        render( 0 );
    }

        /* ── Seeded shuffle (stable within a calendar day) ────────────── */
    function seededShuffle( arr, seed ) {
        var a = arr.slice();
        var s = seed;
        for ( var i = a.length - 1; i > 0; i-- ) {
            /* Xorshift32 */
            s ^= s << 13;
            s ^= s >> 17;
            s ^= s << 5;
            var j = ( ( s >>> 0 ) % ( i + 1 ) );
            var t = a[i]; a[i] = a[j]; a[j] = t;
        }
        return a;
    }

}() );