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.
More actions
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 & 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