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

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.
Joe Beaudoin Jr. (talk | contribs)
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...."
 
Joe Beaudoin Jr. (talk | contribs)
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&nbsp;of&nbsp;Contrition'
local function displayTitle(title)
	local stripped = title:gsub('%s*%(.*$', '')
	return (stripped:gsub(' ', '&nbsp;'))
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 .. '&nbsp;[[' .. 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, ',&nbsp;')
	end
	if n == 2 then
		return parts[1] .. '&nbsp;and&nbsp;' .. parts[2]
	end
	return table.concat(parts, ',&nbsp;', 1, n - 1)
		.. '&nbsp;and&nbsp;'
		.. 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 .. ':&nbsp;' .. 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, ',&nbsp;')
	if prose then
		return label .. ':&nbsp;' .. body
	end
	return '<span class="source-citation"><sup>(' .. label .. ':&nbsp;' .. 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 ';&nbsp;' 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>&#123;&#123;%s|Episode Title&#125;&#125;</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