Module:EpisodeCite: 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.
More actions
Created page with "-------------------------------------------------------------------------------- -- Module:EpisodeCite -- -- Unified inline episode-citation renderer for Battlestar Wiki, replacing -- the wikitext template family: -- -- {{CAP}}, {{TRS}}, {{OS}}, {{G80}}, {{RDMep}} → p.cite -- {{TRS video}}, {{TOS video}} → p.video -- {{Diswitch}} → p.resolve (+ internal) -- -- The wrapper templates become one-liners, e.g...." |
m Protected "Module:EpisodeCite": Sensitive Page ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite)) |
(No difference)
| |
Revision as of 00:18, 6 July 2026
Documentation for this module may be created at Module:EpisodeCite/doc
--------------------------------------------------------------------------------
-- Module:EpisodeCite
--
-- Unified inline episode-citation renderer for Battlestar Wiki, replacing
-- the wikitext template family:
--
-- {{CAP}}, {{TRS}}, {{OS}}, {{G80}}, {{RDMep}} → p.cite
-- {{TRS video}}, {{TOS video}} → p.video
-- {{Diswitch}} → p.resolve (+ internal)
--
-- The wrapper templates become one-liners, e.g. Template:TRS:
--
-- <includeonly>{{#invoke:EpisodeCite|cite|series=TRS}}</includeonly>
--
-- All user-supplied parameters ({{{1}}}..{{{n}}}, del1..deln, prose, nocat)
-- pass through the parent frame automatically. Output is byte-compatible
-- with the templates it replaces, with two deliberate improvements:
--
-- 1. No hard cap on the number of cited episodes (the old templates
-- documented 8; #fornumargs actually handled more, and so does this).
-- 2. Cited titles are validated against the locked episode lists in
-- Module:EpisodeCite/data; unrecognized titles add the page to a
-- tracking category so typos surface instead of silently redlinking.
--
-- Targets Scribunto (Lua 5.1). MW 1.42+.
--------------------------------------------------------------------------------
local p = {}
local data = mw.loadData('Module:EpisodeCite/data')
--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------
-- MediaWiki truthiness for template parameters: present and non-blank.
local function isYes(v)
return v ~= nil and mw.text.trim(v) ~= ''
end
-- Resolve a typed episode title to its actual article title.
-- Faithful port of Template:Diswitch: the TOS map for series=TOS,
-- the shared default map for everything else; fall through unchanged.
local function resolveTitle(title, diswitchKey)
local map = data.diswitch[diswitchKey]
if map and map[title] then
return map[title]
end
return title
end
-- Display text for an episode link. Faithful port of Template:0 —
-- truncates the *typed* title at its first '(' (whether or not preceded
-- by a space, matching the original #explode), drops any whitespace
-- before it, and joins the remaining words with non-breaking spaces so
-- the quoted title never wraps mid-name:
-- 'Pegasus (episode)' → 'Pegasus'
-- 'Act of Contrition' → 'Act of Contrition'
local function displayTitle(title)
local stripped = title:gsub('%s*%(.*$', '')
return (stripped:gsub(' ', ' '))
end
-- One quoted, linked episode citation, plus its optional deleted-scene link.
local function renderEpisode(title, cfg, deleted)
local article = resolveTitle(title, cfg.diswitchKey)
local display = displayTitle(title)
local link
if article == display then
link = string.format('"[[%s]]"', article)
else
link = string.format('"[[%s|%s]]"', article, display)
end
if deleted then
local target
if cfg.delLink == 'caprica-list' then
target = 'List of Deleted Scenes (Caprica)#' .. title
elseif cfg.delLink == 'subpage-raw' then
target = title .. '/Deleted Scenes'
else -- 'subpage'
target = article .. '/Deleted scenes'
end
link = link .. ' [[' .. target .. '|deleted scene]]'
end
return link, article
end
-- Join rendered citations. serialAnd=true reproduces the TRS/CAP/RDMep
-- pattern ("A" and "B" / "A", "B" and "C"); false reproduces OS/G80
-- (plain commas throughout).
local function joinCitations(parts, serialAnd)
local n = #parts
if n == 1 then
return parts[1]
end
if not serialAnd then
return table.concat(parts, ', ')
end
if n == 2 then
return parts[1] .. ' and ' .. parts[2]
end
return table.concat(parts, ', ', 1, n - 1)
.. ' and '
.. parts[n]
end
-- Membership test against the locked episode lists (resolved titles).
-- Built lazily per series and memoized outside the mw.loadData table.
local knownCache = {}
local function isKnownEpisode(article, seriesKey)
local set = knownCache[seriesKey]
if not set then
set = {}
local sources = data.episodeUnion[seriesKey] or { seriesKey }
for _, src in ipairs(sources) do
for _, ep in ipairs(data.episodes[src] or {}) do
set[ep] = true
end
end
knownCache[seriesKey] = set
end
return set[article]
end
--------------------------------------------------------------------------------
-- Reverse index: episode title (as typed OR as resolved) → owning series.
--
-- Built once per page parse (memoized in a module-local, since the source
-- table from mw.loadData is read-only and must stay pure data). Powers
-- series auto-detection for the new series-less universal template —
-- editors no longer need to know or choose which of CAP/TRS/OS/G80 an
-- episode belongs to.
--
-- Each series in data.seriesOrder contributes both its resolved article
-- titles AND its diswitch keys (the short/typed forms editors actually
-- use, e.g. "Pilot", "Home", "The Super Scouts"), so lookups succeed on
-- either form. RDMep is excluded: it's a citation FORMAT (a fixed CAP+TRS
-- combination), not a franchise series, and is never an auto-detection
-- target — it stays an explicit, opt-in choice.
--
-- A title claimed by more than one series is recorded as AMBIGUOUS
-- (value `true`) rather than a series key, so callers can detect the
-- collision instead of silently picking one. None exist in the current
-- locked data — this is future-proofing against any correction that
-- introduces one.
--------------------------------------------------------------------------------
local reverseIndexCache
local function buildReverseIndex()
if reverseIndexCache then
return reverseIndexCache
end
local index = {}
local function claim(title, seriesKey)
local existing = index[title]
if existing == nil then
index[title] = seriesKey
elseif existing ~= seriesKey then
index[title] = true -- ambiguous marker
end
end
-- Pass 1: every locked article title belongs to its series.
for _, seriesKey in ipairs(data.seriesOrder) do
for _, article in ipairs(data.episodes[seriesKey] or {}) do
claim(article, seriesKey)
end
end
-- Pass 2: typed short forms (diswitch keys). Ownership CANNOT be
-- "every series sharing the map" — the default map is shared by
-- G80/CAP/TRS/BC, which would make every short form ambiguous.
-- Instead, a typed form belongs to whichever series' episode list
-- contains the article it RESOLVES to. Where different maps resolve
-- the same typed form into different series (e.g. "The Hand of God"
-- → TOS article via the TOS map, RDM article via the default map),
-- it is claimed by both and correctly lands on the ambiguous marker:
-- that short form genuinely cannot be auto-detected and the editor
-- must use an explicit-series template for it.
for _, map in pairs(data.diswitch) do
for typed, resolved in pairs(map) do
local owner = index[resolved]
if type(owner) == 'string' then
claim(typed, owner)
end
end
end
reverseIndexCache = index
return index
end
-- Returns seriesKey, "AMBIGUOUS", or nil (unrecognized) for a typed title.
local function detectSeries(title)
local result = buildReverseIndex()[title]
if result == true then
return nil, true -- ambiguous
end
return result, false
end
--------------------------------------------------------------------------------
-- Core renderer, shared by p.cite and p.video
--------------------------------------------------------------------------------
-- Render one series' worth of episode entries (all belonging to the same
-- cfg) into a citation body. This is the part of the original single-
-- template logic that stays identical whether it's handling ALL of a
-- call's titles (explicit series= given) or just one bucket of a
-- multi-series auto-detected call.
--
-- Returns: body wikitext (already prose- or parenthetical-wrapped),
-- a list of category names (unresolved to [[Category:]] syntax yet), and
-- whether any title in this group failed the known-episode check.
local function renderGroup(entries, cfg, seriesKey, prose)
local parts, categories, unknown = {}, {}, false
for _, entry in ipairs(entries) do
local deleted = entry.deleted
local link, article = renderEpisode(entry.value, cfg, deleted)
parts[#parts + 1] = link
if cfg.categoryKey == 'resolved' then
categories[#categories + 1] = article
else
categories[#categories + 1] = entry.value
end
if not isKnownEpisode(article, seriesKey) then
unknown = true
end
end
local body = joinCitations(parts, cfg.serialAnd)
local out
if prose then
out = cfg.prosePrefix .. body
else
out = '<span class="source-citation"><sup>('
.. cfg.abbrLink .. ': ' .. body .. ')</sup></span>'
end
return out, categories, unknown
end
-- Fallback rendering for a bucket of titles whose series couldn't be
-- determined (unrecognized) or was ambiguous (matched >1 series). No
-- diswitch/article resolution is attempted — there's no way to know
-- which series' rules would apply — so titles render as plain quoted
-- links using the typed text verbatim for both target and display.
local function renderFallbackGroup(entries, label, prose)
local parts = {}
for _, entry in ipairs(entries) do
parts[#parts + 1] = string.format('"[[%s]]"', entry.value)
end
local body = table.concat(parts, ', ')
if prose then
return label .. ': ' .. body
end
return '<span class="source-citation"><sup>(' .. label .. ': ' .. body .. ')</sup></span>'
end
local function isInMainspace()
return mw.title and mw.title.getCurrentTitle
and mw.title.getCurrentTitle().namespace == 0
end
-- Assemble [[Category:...]] wikitext from episode categories plus any
-- tracking categories. Tracking categories are appended even when
-- nocat=y — nocat suppresses content categorization, not maintenance
-- visibility.
local function formatCats(categories, trackingCats)
local out = {}
for _, c in ipairs(categories) do
out[#out + 1] = string.format('[[Category:%s]]', c)
end
for _, c in ipairs(trackingCats) do
out[#out + 1] = string.format('[[Category:%s]]', c)
end
return table.concat(out)
end
local function buildCitation(args, seriesKey)
-- Collect positional args (episode titles) plus each one's del-flag.
-- #fornumargs in the old templates iterated every supplied numeric
-- parameter, including non-contiguous ones ({{TRS|33|3=Water}} cites
-- both) — so collect all integer keys via pairs() and sort, rather
-- than counting up from 1 and stopping at the first gap. pairs() is
-- required here: Scribunto's frame.args is a lazy metatable-backed
-- table, and pairs() is the only reliable way to enumerate it.
local indices = {}
for k in pairs(args) do
if type(k) == 'number' and k >= 1 and math.floor(k) == k then
indices[#indices + 1] = k
end
end
table.sort(indices)
local titles = {}
for _, idx in ipairs(indices) do
local t = mw.text.trim(args[idx])
if t ~= '' then
titles[#titles + 1] = {
value = t,
index = idx,
deleted = isYes(args['del' .. idx]),
}
end
end
if #titles == 0 then
return nil, nil -- nothing to cite; caller decides how to fail
end
local prose = isYes(args.prose)
local suppressCats = isYes(args.nocat)
local trackingCats = {}
------------------------------------------------------------------------
-- Explicit series (existing wrapper templates: CAP/TRS/OS/G80/RDMep).
-- Unchanged behavior: every title renders under the one given series,
-- with no auto-detection or grouping.
------------------------------------------------------------------------
if seriesKey and mw.text.trim(seriesKey) ~= '' then
local cfg = data.series[seriesKey]
if not cfg then
error(string.format(
'EpisodeCite: unknown series %q (expected one of CAP, TRS, OS, G80, RDMep, BC)',
tostring(seriesKey)), 0)
end
local out, categories, unknown = renderGroup(titles, cfg, seriesKey, prose)
if unknown and isInMainspace() then
trackingCats[#trackingCats + 1] = data.unknownEpisodeCategory
end
return out, formatCats(suppressCats and {} or categories, trackingCats)
end
------------------------------------------------------------------------
-- Auto-detect (the new series-less universal template): bucket each
-- title by its detected series, order buckets per data.seriesOrder,
-- render each bucket independently, then concatenate.
------------------------------------------------------------------------
local buckets, bucketOrder = {}, {}
local ambiguousBucket, unknownBucket = {}, {}
for _, entry in ipairs(titles) do
local detected, ambiguous = detectSeries(entry.value)
if ambiguous then
ambiguousBucket[#ambiguousBucket + 1] = entry
elseif detected then
if not buckets[detected] then
buckets[detected] = {}
bucketOrder[#bucketOrder + 1] = detected
end
table.insert(buckets[detected], entry)
else
unknownBucket[#unknownBucket + 1] = entry
end
end
-- Sort known buckets by canonical franchise order.
local rank = {}
for i, key in ipairs(data.seriesOrder) do
rank[key] = i
end
table.sort(bucketOrder, function(a, b) return rank[a] < rank[b] end)
local outputs, allCategories, anyUnknownEpisode = {}, {}, false
for _, seriesK in ipairs(bucketOrder) do
local out, cats, unknown = renderGroup(buckets[seriesK], data.series[seriesK], seriesK, prose)
outputs[#outputs + 1] = out
if not suppressCats then
for _, c in ipairs(cats) do
allCategories[#allCategories + 1] = c
end
end
if unknown then anyUnknownEpisode = true end
end
if #ambiguousBucket > 0 then
outputs[#outputs + 1] = renderFallbackGroup(ambiguousBucket, 'ambiguous series', prose)
trackingCats[#trackingCats + 1] = data.ambiguousSeriesCategory
end
if #unknownBucket > 0 then
outputs[#outputs + 1] = renderFallbackGroup(unknownBucket, 'unresolved series', prose)
trackingCats[#trackingCats + 1] = data.unresolvedSeriesCategory
end
if anyUnknownEpisode and isInMainspace() then
trackingCats[#trackingCats + 1] = data.unknownEpisodeCategory
end
local joiner = prose and '; ' or ' '
return table.concat(outputs, joiner), formatCats(allCategories, trackingCats)
end
--------------------------------------------------------------------------------
-- Public entry points
--------------------------------------------------------------------------------
-- Resolve which args table holds the user's parameters.
--
-- Invoked through a wrapper template ({{TRS|33}}), the user's parameters
-- live in frame:getParent().args. Invoked directly on a page
-- ({{#invoke:EpisodeCite|cite|series=TRS|33}}), getParent() is NOT nil —
-- it returns the page's own frame with an EMPTY args table — so "parent
-- or frame" selection silently reads zero arguments. Instead: use the
-- parent args if they contain anything at all, else fall back to
-- frame.args. Emptiness must be tested with pairs(), not next() or #,
-- because Scribunto args tables are lazy metatable proxies that only
-- expand through the patched pairs().
local function getArgs(frame)
local parent = frame.getParent and frame:getParent() or nil
local pargs = parent and parent.args
if pargs then
for _ in pairs(pargs) do
return pargs
end
end
return frame.args
end
-- {{#invoke:EpisodeCite|cite|series=TRS}}
-- Replaces {{CAP}} / {{TRS}} / {{OS}} / {{G80}} / {{RDMep}}.
--
-- With NO series parameter — {{#invoke:EpisodeCite|cite}} via the new
-- universal wrapper {{Ep}} — the series of each episode is auto-detected
-- from the locked lists, titles are grouped per series, and groups render
-- in canonical franchise order (TOS, 1980, Caprica, B&C, TRS) regardless
-- of the order the editor typed them.
function p.cite(frame)
local args = getArgs(frame)
local seriesKey = frame.args.series or args.series
local out, cats = buildCitation(args, seriesKey)
if not out then
return string.format(
'<strong class="error">EpisodeCite: no episode title given ' ..
'(usage: <code>{{%s|Episode Title}}</code>)</strong>',
(seriesKey and seriesKey ~= '' and seriesKey) or 'Ep')
end
return out .. cats
end
-- {{#invoke:EpisodeCite|video|series=TRS}}
-- Replaces {{TRS video}} / {{TOS video}}: prose-form single-episode
-- citation with a timestamp, for use inside <ref> tags.
function p.video(frame)
local args = getArgs(frame)
local seriesKey = frame.args.series or args.series
local citeArgs = { args[1], prose = 'y' }
local out, cats = buildCitation(citeArgs, seriesKey)
if not out then
return '<strong class="error">EpisodeCite: no episode title given</strong>'
end
local stamp = args[2] and mw.text.trim(args[2]) or ''
if stamp == '' then
return out .. cats .. ', at an unspecified time index'
end
return out .. cats .. ', at time index ' .. stamp
end
-- {{#invoke:EpisodeCite|resolve|series=TOS|Title}}
-- Direct replacement for {{Diswitch}}, for any other templates that
-- transclude it outside the citation family.
function p.resolve(frame)
local args = getArgs(frame)
local title = args[1]
if not title then
return ''
end
title = mw.text.trim(title)
local seriesArg = args.series
local key = (seriesArg and mw.text.trim(seriesArg) == 'TOS') and 'TOS' or 'default'
return resolveTitle(title, key)
end
--------------------------------------------------------------------------------
-- p.count — episode counts, for infoboxes/stats pages.
--
-- {{#invoke:EpisodeCite|count|series=TRS}} → 78
-- {{#invoke:EpisodeCite|count}} → sum across all franchise
-- series (BC included, RDMep
-- excluded — it's not a
-- series, just a format)
--------------------------------------------------------------------------------
function p.count(frame)
local args = getArgs(frame)
local seriesKey = frame.args.series or args.series
if seriesKey and seriesKey ~= '' then
local list = data.episodes[seriesKey]
if not list then
error(string.format('EpisodeCite: unknown series %q for count', seriesKey), 0)
end
return #list
end
local total = 0
for _, sk in ipairs(data.seriesOrder) do
total = total + #(data.episodes[sk] or {})
end
return total
end
--------------------------------------------------------------------------------
-- p.adjacent — previous/next episode within a series' locked (ordered!)
-- list, keyed off the same title-matching rules as citations.
--
-- {{#invoke:EpisodeCite|adjacent|33|which=next}} → "Water"
-- {{#invoke:EpisodeCite|adjacent|33|which=prev}} → "" (first episode)
-- {{#invoke:EpisodeCite|adjacent|33|series=TRS}} → forces series
-- instead of auto-detecting (same override rule as p.cite)
--
-- Returns the ADJACENT EPISODE'S LOCKED ARTICLE TITLE (not the typed
-- short form) — callers building a nav link should wrap it themselves,
-- e.g. [[{{#invoke:EpisodeCite|adjacent|...}}|Next episode →]], so this
-- stays a plain data lookup usable by infoboxes, navboxes, or anything
-- else, not just one hardcoded link shape.
--
-- Returns empty string at either end of a series (no wraparound) or if
-- the title/series can't be resolved — never errors, since this is meant
-- to sit unattended inside hundreds of infobox transclusions.
--------------------------------------------------------------------------------
function p.adjacent(frame)
local args = getArgs(frame)
local title = args[1] and mw.text.trim(args[1])
if not title or title == '' then
return ''
end
-- Normalize direction. Case-insensitive, with common synonyms. Empty
-- or absent defaults to "next". An unrecognized NON-EMPTY value returns
-- '' rather than silently falling through to "next" — a navbox that
-- says which='Prev' (now handled) or has a real typo must not quietly
-- produce a forward link labeled "previous".
local rawWhich = args.which and mw.text.trim(args.which):lower() or ''
if rawWhich == '' then
rawWhich = 'next'
end
local delta
if rawWhich == 'next' or rawWhich == 'n' or rawWhich == 'forward' then
delta = 1
elseif rawWhich == 'prev' or rawWhich == 'previous' or rawWhich == 'p' or rawWhich == 'back' then
delta = -1
else
return '' -- unrecognized direction; refuse to guess
end
local seriesKey = args.series and mw.text.trim(args.series)
if not seriesKey or seriesKey == '' then
local detected, ambiguous = detectSeries(title)
if ambiguous or not detected then
return '' -- can't safely guess; caller should pass series= explicitly
end
seriesKey = detected
end
local cfg = data.series[seriesKey]
local list = data.episodes[seriesKey]
if not cfg or not list then
return ''
end
local article = resolveTitle(title, cfg.diswitchKey)
local pos
for i, ep in ipairs(list) do
if ep == article then
pos = i
break
end
end
if not pos then
return '' -- title isn't in the locked list at all
end
return list[pos + delta] or ''
end
--------------------------------------------------------------------------------
-- p.audit — internal data-integrity self-check, NOT meant for wiki
-- transclusion. Verifies every diswitch entry's resolved article actually
-- appears in the episode list of the series that owns it, per the same
-- ownership rule buildReverseIndex uses. Catches transcription mistakes
-- in a data file that's supposed to be permanently correct (the "Occupation
-- /Precipice mislabeled as webisodes" class of bug from the last audit —
-- that one was a comment error, not a data error, but a resolved-article
-- typo would be exactly this kind of silent breakage: the citation would
-- still render, just linking a title that doesn't exist in the locked
-- list, invisible unless someone checks).
--
-- Run via p._audit() in tests, or temporarily wire to a debug entry point
-- if a live check is ever wanted.
--------------------------------------------------------------------------------
local function audit()
local problems = {}
local owner = {}
for _, sk in ipairs(data.seriesOrder) do
for _, article in ipairs(data.episodes[sk] or {}) do
owner[article] = owner[article] and true or sk
end
end
for mapName, map in pairs(data.diswitch) do
for typed, resolved in pairs(map) do
if owner[resolved] == nil then
problems[#problems + 1] = string.format(
'diswitch.%s[%q] resolves to %q, which is not in ANY locked episode list',
mapName, typed, resolved)
end
end
end
return problems
end
-- Expose internals for testing.
p._buildCitation = buildCitation
p._resolveTitle = resolveTitle
p._displayTitle = displayTitle
p._joinCitations = joinCitations
p._detectSeries = detectSeries
p._audit = audit
return p