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

Module:Portal: Difference between revisions

From the only original and legitimate Battlestar Wiki: the free-as-in-beer, non-corporate, open-content encyclopedia, analytical reference, and episode guide on all things Battlestar Galactica. Accept neither subpar substitutes nor subpar clones.
Joe Beaudoin Jr. (talk | contribs)
Created page with "-- Module:Portal -- Provides dynamic portal widgets for BattlestarWiki: -- stats bar, newest page, random image, colonial calendar -- -- Usage (from Template:Portal/Layout): -- {{#invoke:Portal|stats|category=Characters (RDM)|episodes=73|characters=62|seasons=4|year=2003}} -- {{#invoke:Portal|newestPage|category=Characters (RDM)}} -- {{#invoke:Portal|randomImage|category=Images (RDM)}} -- {{#invoke:Portal|colonialCalendar|category=RDM}} local p = {} local mwTi..."
 
Joe Beaudoin Jr. (talk | contribs)
No edit summary
Line 1: Line 1:
-- Module:Portal
-- Module:Portal
-- Provides dynamic portal widgets for BattlestarWiki:
-- Provides server-side portal widgets for BattlestarWiki.
--  stats bar, newest page, random image, colonial calendar
--
--
-- Usage (from Template:Portal/Layout):
-- Functions:
--  {{#invoke:Portal|stats|category=Characters (RDM)|episodes=73|characters=62|seasons=4|year=2003}}
--  colonialCalendar  — "On this day in BSG" sidebar widget
--   {{#invoke:Portal|newestPage|category=Characters (RDM)}}
--  relatedPortals    — related-portals tile grid
--   {{#invoke:Portal|randomImage|category=Images (RDM)}}
--
--   {{#invoke:Portal|colonialCalendar|category=RDM}}
-- NOTE: stats, newest article, and image carousel are handled
-- entirely by the portal JS in MediaWiki:Common.js, not Lua.
-- The JS reads data-category / data-listpage attributes set by
-- Template:Portal/Layout and makes live API calls.


local p = {}
local p = {}
local mwTitle  = mw.title
local mwHtml   = mw.html
local mwHtml   = mw.html
local mwSite  = mw.site
local mwUstring = mw.ustring
local mwUstring = mw.ustring


Line 19: Line 19:
------------------------------------------------------------------------
------------------------------------------------------------------------


--- Trim whitespace from both ends of a string.
local function trim( s )
local function trim(s)
     return s and mwUstring.match( s, '^%s*(.-)%s*$' ) or ''
     return s and mwUstring.match(s, '^%s*(.-)%s*$') or ''
end
end


--- Return the value of an arg, trimmed, or a default.
local function arg( args, key, default )
local function arg(args, key, default)
     local v = args[key]
     local v = args[key]
     return (v and trim(v) ~= '') and trim(v) or default
     return ( v and trim( v ) ~= '' ) and trim( v ) or default
end
 
--- Build a CSS class string, filtering nils.
local function classes(...)
    local t = {}
    for _, c in ipairs({...}) do
        if c then t[#t+1] = c end
    end
    return table.concat(t, ' ')
end
 
--- Get the count of members in a MediaWiki category via mw.title magic.
--- Returns nil if category doesn't exist.
local function getCategoryCount(catName)
    local t = mwTitle.new('Category:' .. catName)
    if not t then return nil end
    -- pagesInCategory returns a table {all=N, files=N, subcats=N, pages=N}
    -- Needs to be called through the expensive-function path; we use the
    -- parser function as a safer fallback.
    local ok, count = pcall(function()
        return t.pagesInCategory
    end)
    if ok and count then return count end
    return nil
end
 
--- Fetch the most recently created page in a category.
--- Returns {title, excerpt, timestamp} or nil.
--- NOTE: mw.title API doesn't support category member queries directly;
--- we use mw.ext.cargo if available, otherwise return nil so the template
--- can show a static fallback. In production this should be backed by a
--- Cargo table or a Lua-side API call cached in frame:callParserFunction.
local function getNewestInCategory(catName)
    -- Cargo query (requires Cargo extension + populated tables)
    if mw.ext and mw.ext.cargo then
        local ok, rows = pcall(function()
            return mw.ext.cargo.query(
                'Pages',
                '_pageName,_pageTitle,_creationDate',
                {
                    where  = '_category = "' .. catName .. '"',
                    orderBy = '_creationDate DESC',
                    limit  = 1
                }
            )
        end)
        if ok and rows and rows[1] then
            local r = rows[1]
            return {
                title    = r._pageTitle or r._pageName,
                pageName  = r._pageName,
                timestamp = r._creationDate
            }
        end
    end
    -- Fallback: nil — template renders a static placeholder message
    return nil
end
 
--- Return a random file from a category.
--- In a live wiki this uses mw.title combined with randomness seeded by
--- the current date so the image changes daily rather than on every purge.
--- Returns a file title string or nil.
local function getDailyRandomImage(catName)
    -- Seed by date so the same image shows all day (stable across purges)
    local dateStr = mw.getCurrentFrame():callParserFunction('#time', 'Ymd')
    local seed    = tonumber(dateStr) or 20240101
    math.randomseed(seed)
 
    -- We can't enumerate category members purely in Lua/Scribunto, so we
    -- depend on either:
    --  (a) A companion sub-page Portal:XXX/ImageList  (one filename per line)
    --  (b) The RandomInCategory extension
    --  (c) A Cargo table of files tagged with the category
    --
    -- Strategy: try sub-page first, then RandomInCategory magic word, else nil.
 
    -- (a) Sub-page list  e.g. "Portal:Battlestar Galactica (RDM)/ImageList"
    --    Each line: File:Foo.jpg | optional caption
    local listPage = mwTitle.getCurrentTitle().prefixedText .. '/ImageList'
    local listTitle = mwTitle.new(listPage)
    if listTitle and listTitle.exists then
        local content = listTitle:getContent()
        if content then
            local files = {}
            for line in content:gmatch('[^\n]+') do
                local fname = trim(line:match('^([^|]+)'))
                if fname ~= '' then
                    files[#files+1] = fname
                end
            end
            if #files > 0 then
                return files[math.random(#files)]
            end
        end
    end
 
    -- (b) Nothing found — return nil; template uses a placeholder
    return nil
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- Colonial Calendar data
-- Colonial Calendar
-- Keyed as "M-D" (no zero-padding) → list of {year, text, link}
-- Keyed "M-D" (no zero-padding) → list of {year, text}
-- Covers real-world broadcast dates and in-universe events.
-- Covers real-world BSG broadcast dates.
-- Editors can extend this table or replace it with a data module.
-- To extend: add entries here, or extract to Module:Portal/Calendar
-- (see DEPLOYMENT.md) so non-Lua editors can add dates directly.
------------------------------------------------------------------------
------------------------------------------------------------------------


local CALENDAR = {
local CALENDAR = {
     ['1-14'] = {
     ['1-14'] = {
         { year='2005', text='[[33 (episode)|33]] premieres on Sci Fi Channel — the first regular episode of the Re-imagined Series', link='33 (episode)' },
         { year = '2005', text = '[[33 (episode)|33]] premieres on Sci Fi Channel — first regular RDM episode' },
     },
     },
     ['1-21'] = {
     ['1-21'] = {
         { year='2005', text='[[Water (episode)|Water]] airs on Sci Fi Channel', link='Water (episode)' },
         { year = '2005', text = '[[Water (episode)|Water]] airs on Sci Fi Channel' },
     },
     },
     ['3-4'] = {
     ['3-4'] = {
         { year='2005', text='[[Tigh Me Up, Tigh Me Down]] airs on Sky One (UK)', link='Tigh Me Up, Tigh Me Down' },
         { year = '2005', text = '[[Tigh Me Up, Tigh Me Down]] airs on Sky One (UK)' },
     },
     },
     ['3-18'] = {
     ['3-18'] = {
         { year='2005', text='[[Colonial Day]] airs on Sky One', link='Colonial Day' },
         { year = '2005', text = '[[Colonial Day]] airs on Sky One' },
     },
     },
     ['3-20'] = {
     ['3-20'] = {
         { year='2009', text='[[Daybreak, Part II|Daybreak]] finale airs — end of the Re-imagined Series', link='Daybreak, Part II' },
         { year = '2009', text = '[[Daybreak, Part II|Daybreak]] finale airs — end of the Re-imagined Series' },
     },
     },
     ['4-1'] = {
     ['4-1'] = {
         { year='2005', text='[[Kobol\'s Last Gleaming, Part II]] airs — Season 1 finale', link="Kobol's Last Gleaming, Part II" },
         { year = '2005', text = "[[Kobol's Last Gleaming, Part II]] airs — Season 1 finale" },
     },
     },
     ['4-12'] = {
     ['4-12'] = {
         { year='2007', text='[[The Woman King]] airs on Sci Fi Channel', link='The Woman King' },
         { year = '2007', text = '[[The Woman King]] airs on Sci Fi Channel' },
         { year='2009', text='[[Islanded in a Stream of Stars]] airs — three episodes remain', link='Islanded in a Stream of Stars' },
         { year = '2009', text = '[[Islanded in a Stream of Stars]] airs — three episodes remain' },
     },
     },
     ['6-3'] = {
     ['6-3'] = {
         { year='2005', text='[[Scattered]] airs — Season 2 premiere', link='Scattered' },
         { year = '2005', text = '[[Scattered]] airs — Season 2 premiere' },
     },
     },
     ['9-22'] = {
     ['9-22'] = {
         { year='2006', text='[[Occupation]]/[[Precipice]] two-hour Season 3 premiere airs', link='Occupation' },
         { year = '2006', text = '[[Occupation]]/[[Precipice]] two-hour Season 3 premiere airs' },
     },
     },
     ['10-6'] = {
     ['10-6'] = {
         { year='2006', text='[[Exodus, Part II]] airs — fan-favourite battle sequence', link='Exodus, Part II' },
         { year = '2006', text = '[[Exodus, Part II]] airs — fan-favourite Galactica dive sequence' },
     },
     },
     ['12-8'] = {
     ['12-8'] = {
         { year='2003', text='[[Miniseries, Night 1|BSG Miniseries]] premieres on Sci Fi Channel', link='Miniseries, Night 1' },
         { year = '2003', text = '[[Miniseries, Night 1|BSG Miniseries]] premieres on Sci Fi Channel' },
     },
     },
}
}


-- Build lookup normalised to "M-D" (strips leading zeros)
local function todayKey()
local function todayKey()
     local m = tonumber(mw.getCurrentFrame():callParserFunction('#time', 'n'))
     local frame = mw.getCurrentFrame()
     local d = tonumber(mw.getCurrentFrame():callParserFunction('#time', 'j'))
    local m = tonumber( frame:callParserFunction( '#time', 'n' ) )
     local d = tonumber( frame:callParserFunction( '#time', 'j' ) )
     return m .. '-' .. d
     return m .. '-' .. d
end
end


------------------------------------------------------------------------
function p.colonialCalendar( frame )
-- Public: stats
-- Args: category, episodes, characters, seasons, year
-- Any missing numeric arg is omitted from the bar.
------------------------------------------------------------------------
function p.stats(frame)
    local args = frame:getParent().args
    local cat  = arg(args, 'category', '')
 
    -- Collect stat items: {num, label}
    local items = {}
 
    -- Article count from category
    if cat ~= '' then
        local n = getCategoryCount(cat)
        if n then
            items[#items+1] = { num = tostring(n), label = 'articles' }
        end
    end
 
    -- Manual counts passed as template args
    local function addStat(key, label)
        local v = arg(args, key, nil)
        if v then items[#items+1] = { num = v, label = label } end
    end
    addStat('episodes',  'episodes')
    addStat('characters', 'characters')
    addStat('seasons',    'seasons')
    addStat('year',      'first aired')
 
    if #items == 0 then return '' end
 
    local root = mwHtml.create('div'):addClass('portal-stats-bar')
    for _, item in ipairs(items) do
        root:tag('div'):addClass('portal-stat')
            :tag('div'):addClass('portal-stat-num'):wikitext(item.num):done()
            :tag('div'):addClass('portal-stat-label'):wikitext(item.label):done()
    end
    return tostring(root)
end
 
------------------------------------------------------------------------
-- Public: newestPage
-- Args: category
-- Returns an HTML block for the newest article widget body.
------------------------------------------------------------------------
function p.newestPage(frame)
    local args    = frame:getParent().args
    local cat    = arg(args, 'category', '')
    local fallback = arg(args, 'fallback', '')
 
    local newest = (cat ~= '') and getNewestInCategory(cat) or nil
 
    local root = mwHtml.create('div'):addClass('portal-newest')
 
    if newest then
        root:tag('div'):addClass('portal-newest-label')
            :wikitext('Newest · Category:' .. cat)
        root:tag('div'):addClass('portal-newest-title')
            :wikitext('[[' .. newest.pageName .. '|' .. newest.title .. ']]')
        if newest.timestamp then
            root:tag('div'):addClass('portal-newest-meta')
                :wikitext('Created ' .. newest.timestamp)
        end
    elseif fallback ~= '' then
        -- Static fallback provided by the template editor
        root:wikitext(fallback)
    else
        root:tag('div'):addClass('portal-newest-placeholder')
            :wikitext('<small>[[Special:NewPages|Browse new articles]]'
                .. (cat ~= '' and ' · filtered to [[:Category:' .. cat .. ']]' or '')
                .. '</small>')
    end
 
    return frame:preprocess(tostring(root))
end
 
------------------------------------------------------------------------
-- Public: randomImage
-- Args: category  (used to find the ImageList sub-page)
-- Returns wikitext for a [[File:...]] transclusion, or placeholder.
------------------------------------------------------------------------
function p.randomImage(frame)
    local args = frame:getParent().args
    local cat  = arg(args, 'category', '')
    local size = arg(args, 'size', '400px')
 
    local file = getDailyRandomImage(cat)
 
    if file then
        -- Normalise: strip leading "File:" if present
        file = file:gsub('^[Ff]ile:', '')
        return '[[File:' .. file .. '|' .. size .. '|portal random image]]'
    end
 
    -- Placeholder div so the layout doesn't collapse
    local ph = mwHtml.create('div'):addClass('portal-image-placeholder')
        :wikitext('No image list found. Create [[' ..
            mwTitle.getCurrentTitle().prefixedText .. '/ImageList]] to enable the random image slider.')
    return tostring(ph)
end
 
------------------------------------------------------------------------
-- Public: colonialCalendar
-- Returns an HTML fragment listing today's BSG events.
-- Args: category (used to filter if needed; currently shows all entries)
------------------------------------------------------------------------
function p.colonialCalendar(frame)
     local key    = todayKey()
     local key    = todayKey()
     local events = CALENDAR[key]
     local events = CALENDAR[key]


    local root = mwHtml.create('div'):addClass('portal-calendar')
     local dateDisplay = mw.getCurrentFrame():callParserFunction( '#time', 'F j' )
 
    -- Date display
     local dateDisplay = mw.getCurrentFrame():callParserFunction('#time', 'F j')
    root:tag('div'):addClass('portal-calendar-date'):wikitext(dateDisplay)


     local sub = root:tag('div'):addClass('portal-calendar-sub')
     local root = mwHtml.create( 'div' ):addClass( 'portal-calendar' )
         :wikitext('Real-world BSG broadcast history')
    root:tag( 'div' ):addClass( 'portal-calendar-date' ):wikitext( dateDisplay )
    root:tag( 'div' ):addClass( 'portal-calendar-sub' )
         :wikitext( 'Real-world BSG broadcast history' )


     if events and #events > 0 then
     if events and #events > 0 then
         local list = root:tag('ul'):addClass('portal-calendar-events')
         local list = root:tag( 'ul' ):addClass( 'portal-calendar-events' )
         for _, ev in ipairs(events) do
         for _, ev in ipairs( events ) do
             list:tag('li'):addClass('portal-calendar-event')
             list:tag( 'li' ):addClass( 'portal-calendar-event' )
                 :tag('span'):addClass('portal-calendar-year'):wikitext(ev.year):done()
                 :tag( 'span' ):addClass( 'portal-calendar-year' ):wikitext( ev.year ):done()
                 :wikitext(' ')
                 :wikitext( ' \xe2\x80\x94 ' .. ev.text )
                :tag('span'):wikitext(ev.text)
         end
         end
     else
     else
         root:tag('div'):addClass('portal-calendar-none')
         root:tag( 'div' ):addClass( 'portal-calendar-none' )
             :wikitext('<small>No recorded BSG events on this date.</small>')
             :wikitext( '<small>No recorded BSG events on this date.</small>' )
     end
     end


     return frame:preprocess(tostring(root))
     return frame:preprocess( tostring( root ) )
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- Public: relatedPortals
-- Related Portals
-- Args: type = 'series' | 'topic'
-- Args:
-- Renders the related-portals tile grid appropriate for the portal type.
--  type     "series" (default) or "topic"
--   exclude  Full page title to hide (this portal itself)
------------------------------------------------------------------------
------------------------------------------------------------------------


local SERIES_PORTALS = {
local SERIES_PORTALS = {
     { title = 'Portal:Battlestar Galactica (TOS)', label = 'Original Series',    sub = 'TOS · 1978' },
     { title = 'Portal:Battlestar Galactica (TOS)', label = 'Original Series',    sub = 'TOS &middot; 1978' },
     { title = 'Portal:Galactica 1980',            label = 'Galactica 1980',    sub = '1980' },
     { title = 'Portal:Galactica 1980',            label = 'Galactica 1980',    sub = '1980' },
     { title = 'Portal:Battlestar Galactica (RDM)', label = 'Re-imagined Series', sub = 'RDM · 2003–2009' },
     { title = 'Portal:Battlestar Galactica (RDM)', label = 'Re-imagined Series', sub = 'RDM &middot; 2003&ndash;2009' },
     { title = 'Portal:Caprica',                    label = 'Caprica',            sub = 'Prequel · 2010–2011' },
     { title = 'Portal:Caprica',                    label = 'Caprica',            sub = 'Prequel &middot; 2010&ndash;2011' },
     { title = 'Portal:Blood and Chrome',          label = 'Blood &amp; Chrome', sub = 'Prequel · 2012' },
     { title = 'Portal:Blood and Chrome',          label = 'Blood &amp; Chrome', sub = 'Prequel &middot; 2012' },
}
}


local TOPIC_PORTALS = {
local TOPIC_PORTALS = {
     { title = 'Portal:Characters', label = 'Characters', sub = 'All series' },
     { title = 'Portal:Characters', label = 'Characters', sub = 'All series' },
     { title = 'Portal:Ships',     label = 'Ships',     sub = 'All series' },
     { title = 'Portal:Ships',       label = 'Ships',       sub = 'All series' },
     { title = 'Portal:Cylons',     label = 'Cylons',     sub = 'All series' },
     { title = 'Portal:Cylons',     label = 'Cylons',     sub = 'All series' },
     { title = 'Portal:Episodes',   label = 'Episodes',   sub = 'All series' },
     { title = 'Portal:Episodes',   label = 'Episodes',   sub = 'All series' },
     { title = 'Portal:Merchandise',label = 'Merchandise',sub = 'All series' },
     { title = 'Portal:Merchandise', label = 'Merchandise', sub = 'All series' },
}
}


function p.relatedPortals(frame)
function p.relatedPortals( frame )
     local args    = frame:getParent().args
     local args    = frame:getParent().args
    local pType  = arg(args, 'type', 'series')  -- 'series' or 'topic'
     local exclude = arg( args, 'exclude', '' )
     local exclude = arg(args, 'exclude', '')     -- current portal title to skip


    local list = (pType == 'topic') and TOPIC_PORTALS or (SERIES_PORTALS)
    -- Merge both lists for cross-linking
     local combined = {}
     local combined = {}
     for _, v in ipairs(SERIES_PORTALS) do combined[#combined+1] = v end
     for _, v in ipairs( SERIES_PORTALS ) do combined[#combined + 1] = v end
     for _, v in ipairs(TOPIC_PORTALS) do combined[#combined+1] = v end
     for _, v in ipairs( TOPIC_PORTALS ) do combined[#combined + 1] = v end


     local root = mwHtml.create('div'):addClass('portal-related-grid')
     local root = mwHtml.create( 'div' ):addClass( 'portal-related-grid' )


     for _, portal in ipairs(combined) do
     for _, portal in ipairs( combined ) do
         if portal.title ~= exclude then
         if portal.title ~= exclude then
             root:tag('div'):addClass('portal-related-tile')
             root:tag( 'div' ):addClass( 'portal-related-tile' )
                 :tag('div'):addClass('portal-related-title')
                 :tag( 'div' ):addClass( 'portal-related-title' )
                     :wikitext('[[' .. portal.title .. '|' .. portal.label .. ']]'):done()
                     :wikitext( '[[' .. portal.title .. '|' .. portal.label .. ']]' ):done()
                 :tag('div'):addClass('portal-related-sub')
                 :tag( 'div' ):addClass( 'portal-related-sub' )
                     :wikitext(portal.sub)
                     :wikitext( portal.sub )
         end
         end
     end
     end


     return tostring(root)
     return tostring( root )
end
end


return p
return p

Revision as of 20:35, 13 April 2026

Documentation for this module may be created at Module:Portal/doc

-- Module:Portal
-- Provides server-side portal widgets for BattlestarWiki.
--
-- Functions:
--   colonialCalendar  — "On this day in BSG" sidebar widget
--   relatedPortals    — related-portals tile grid
--
-- NOTE: stats, newest article, and image carousel are handled
-- entirely by the portal JS in MediaWiki:Common.js, not Lua.
-- The JS reads data-category / data-listpage attributes set by
-- Template:Portal/Layout and makes live API calls.

local p = {}
local mwHtml    = mw.html
local mwUstring = mw.ustring

------------------------------------------------------------------------
-- Helpers
------------------------------------------------------------------------

local function trim( s )
    return s and mwUstring.match( s, '^%s*(.-)%s*$' ) or ''
end

local function arg( args, key, default )
    local v = args[key]
    return ( v and trim( v ) ~= '' ) and trim( v ) or default
end

------------------------------------------------------------------------
-- Colonial Calendar
-- Keyed "M-D" (no zero-padding) → list of {year, text}
-- Covers real-world BSG broadcast dates.
-- To extend: add entries here, or extract to Module:Portal/Calendar
-- (see DEPLOYMENT.md) so non-Lua editors can add dates directly.
------------------------------------------------------------------------

local CALENDAR = {
    ['1-14'] = {
        { year = '2005', text = '[[33 (episode)|33]] premieres on Sci Fi Channel — first regular RDM episode' },
    },
    ['1-21'] = {
        { year = '2005', text = '[[Water (episode)|Water]] airs on Sci Fi Channel' },
    },
    ['3-4']  = {
        { year = '2005', text = '[[Tigh Me Up, Tigh Me Down]] airs on Sky One (UK)' },
    },
    ['3-18'] = {
        { year = '2005', text = '[[Colonial Day]] airs on Sky One' },
    },
    ['3-20'] = {
        { year = '2009', text = '[[Daybreak, Part II|Daybreak]] finale airs — end of the Re-imagined Series' },
    },
    ['4-1']  = {
        { year = '2005', text = "[[Kobol's Last Gleaming, Part II]] airs — Season 1 finale" },
    },
    ['4-12'] = {
        { year = '2007', text = '[[The Woman King]] airs on Sci Fi Channel' },
        { year = '2009', text = '[[Islanded in a Stream of Stars]] airs — three episodes remain' },
    },
    ['6-3']  = {
        { year = '2005', text = '[[Scattered]] airs — Season 2 premiere' },
    },
    ['9-22'] = {
        { year = '2006', text = '[[Occupation]]/[[Precipice]] two-hour Season 3 premiere airs' },
    },
    ['10-6'] = {
        { year = '2006', text = '[[Exodus, Part II]] airs — fan-favourite Galactica dive sequence' },
    },
    ['12-8'] = {
        { year = '2003', text = '[[Miniseries, Night 1|BSG Miniseries]] premieres on Sci Fi Channel' },
    },
}

local function todayKey()
    local frame = mw.getCurrentFrame()
    local m = tonumber( frame:callParserFunction( '#time', 'n' ) )
    local d = tonumber( frame:callParserFunction( '#time', 'j' ) )
    return m .. '-' .. d
end

function p.colonialCalendar( frame )
    local key    = todayKey()
    local events = CALENDAR[key]

    local dateDisplay = mw.getCurrentFrame():callParserFunction( '#time', 'F j' )

    local root = mwHtml.create( 'div' ):addClass( 'portal-calendar' )
    root:tag( 'div' ):addClass( 'portal-calendar-date' ):wikitext( dateDisplay )
    root:tag( 'div' ):addClass( 'portal-calendar-sub' )
        :wikitext( 'Real-world BSG broadcast history' )

    if events and #events > 0 then
        local list = root:tag( 'ul' ):addClass( 'portal-calendar-events' )
        for _, ev in ipairs( events ) do
            list:tag( 'li' ):addClass( 'portal-calendar-event' )
                :tag( 'span' ):addClass( 'portal-calendar-year' ):wikitext( ev.year ):done()
                :wikitext( ' \xe2\x80\x94 ' .. ev.text )
        end
    else
        root:tag( 'div' ):addClass( 'portal-calendar-none' )
            :wikitext( '<small>No recorded BSG events on this date.</small>' )
    end

    return frame:preprocess( tostring( root ) )
end

------------------------------------------------------------------------
-- Related Portals
-- Args:
--   type     "series" (default) or "topic"
--   exclude  Full page title to hide (this portal itself)
------------------------------------------------------------------------

local SERIES_PORTALS = {
    { title = 'Portal:Battlestar Galactica (TOS)', label = 'Original Series',    sub = 'TOS &middot; 1978' },
    { title = 'Portal:Galactica 1980',             label = 'Galactica 1980',     sub = '1980' },
    { title = 'Portal:Battlestar Galactica (RDM)', label = 'Re-imagined Series', sub = 'RDM &middot; 2003&ndash;2009' },
    { title = 'Portal:Caprica',                    label = 'Caprica',            sub = 'Prequel &middot; 2010&ndash;2011' },
    { title = 'Portal:Blood and Chrome',           label = 'Blood &amp; Chrome', sub = 'Prequel &middot; 2012' },
}

local TOPIC_PORTALS = {
    { title = 'Portal:Characters',  label = 'Characters',  sub = 'All series' },
    { title = 'Portal:Ships',       label = 'Ships',       sub = 'All series' },
    { title = 'Portal:Cylons',      label = 'Cylons',      sub = 'All series' },
    { title = 'Portal:Episodes',    label = 'Episodes',    sub = 'All series' },
    { title = 'Portal:Merchandise', label = 'Merchandise', sub = 'All series' },
}

function p.relatedPortals( frame )
    local args    = frame:getParent().args
    local exclude = arg( args, 'exclude', '' )

    local combined = {}
    for _, v in ipairs( SERIES_PORTALS ) do combined[#combined + 1] = v end
    for _, v in ipairs( TOPIC_PORTALS  ) do combined[#combined + 1] = v end

    local root = mwHtml.create( 'div' ):addClass( 'portal-related-grid' )

    for _, portal in ipairs( combined ) do
        if portal.title ~= exclude then
            root:tag( 'div' ):addClass( 'portal-related-tile' )
                :tag( 'div' ):addClass( 'portal-related-title' )
                    :wikitext( '[[' .. portal.title .. '|' .. portal.label .. ']]' ):done()
                :tag( 'div' ):addClass( 'portal-related-sub' )
                    :wikitext( portal.sub )
        end
    end

    return tostring( root )
end

return p