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

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.
Revision as of 13:57, 13 April 2026 by 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...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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

-- 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 mwTitle  = mw.title
local mwHtml   = mw.html
local mwSite   = mw.site
local mwUstring = mw.ustring

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

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

--- Return the value of an arg, trimmed, or a default.
local function arg(args, key, default)
    local v = args[key]
    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

------------------------------------------------------------------------
-- Colonial Calendar data
-- Keyed as "M-D" (no zero-padding) → list of {year, text, link}
-- Covers real-world broadcast dates and in-universe events.
-- Editors can extend this table or replace it with a data module.
------------------------------------------------------------------------

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

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

------------------------------------------------------------------------
-- 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 events = CALENDAR[key]

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

    -- 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')
        :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(' — ')
                :tag('span'):wikitext(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

------------------------------------------------------------------------
-- Public: relatedPortals
-- Args: type = 'series' | 'topic'
-- Renders the related-portals tile grid appropriate for the portal type.
------------------------------------------------------------------------

local SERIES_PORTALS = {
    { title = 'Portal:Battlestar Galactica (TOS)', label = 'Original Series',    sub = 'TOS · 1978' },
    { title = 'Portal:Galactica 1980',             label = 'Galactica 1980',     sub = '1980' },
    { title = 'Portal:Battlestar Galactica (RDM)', label = 'Re-imagined Series', sub = 'RDM · 2003–2009' },
    { title = 'Portal:Caprica',                    label = 'Caprica',            sub = 'Prequel · 2010–2011' },
    { title = 'Portal:Blood and Chrome',           label = 'Blood &amp; Chrome', sub = 'Prequel · 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 pType   = arg(args, 'type', 'series')  -- 'series' or 'topic'
    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 = {}
    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