Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.
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 00:51, 6 July 2026 by Joe Beaudoin Jr. (talk | contribs)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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.
--
-- wrap (default true): whether to wrap the parenthetical (non-prose) form
-- in its own <span class="source-citation"><sup>(...)</sup></span>. Pass
-- false when this group is one of SEVERAL being merged into one shared
-- wrapper by the caller (see buildCitation's multi-series path) — without
-- this, a call citing an OS episode and a CAP episode together rendered
-- as two separate adjacent parenthetical groups, e.g.
-- "(1980: "X") (CAP: "Y")", which reads as cluttered, disconnected
-- citations rather than one combined one. wrap=false returns just the
-- inner "ABBR:&nbsp;body" content with no span/sup/parens, so several
-- groups can be joined with "; " inside ONE shared wrapper instead:
-- "(1980: "X"; CAP: "Y")". Prose form is unaffected either way — it never
-- had its own wrapper to begin with.
--
-- Returns: body wikitext (prose-prefixed, fully wrapped, or bare core
-- content per the above), 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, wrap)
	if wrap == nil then wrap = true end
	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
	elseif wrap then
		out = '<span class="source-citation"><sup>('
			.. cfg.abbrLink .. ':&nbsp;' .. body .. ')</sup></span>'
	else
		out = cfg.abbrLink .. ':&nbsp;' .. body
	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.
-- wrap works the same way as in renderGroup — see its comment.
local function renderFallbackGroup(entries, label, prose, wrap)
	if wrap == nil then wrap = true end
	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 or not wrap 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 totalGroups = #bucketOrder
		+ (#ambiguousBucket > 0 and 1 or 0)
		+ (#unknownBucket > 0 and 1 or 0)

	-- A single detected group (the common case: {{Ep|33}} or a call whose
	-- titles all happen to be the same series) renders EXACTLY as the
	-- explicit-series path would — wrapped, no merging logic involved.
	-- Merging only kicks in for a genuine multi-series call, where it
	-- replaces what used to be several adjacent, separately-wrapped
	-- parenthetical spans — e.g. "(1980: "X") (CAP: "Y")" — with one
	-- shared wrapper: "(1980: "X"; CAP: "Y")".
	local wrapPerGroup = (totalGroups <= 1)

	local outputs, allCategories, anyUnknownEpisode = {}, {}, false

	for _, seriesK in ipairs(bucketOrder) do
		local out, cats, unknown = renderGroup(
			buckets[seriesK], data.series[seriesK], seriesK, prose, wrapPerGroup)
		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, wrapPerGroup)
		trackingCats[#trackingCats + 1] = data.ambiguousSeriesCategory
	end
	if #unknownBucket > 0 then
		outputs[#outputs + 1] = renderFallbackGroup(unknownBucket, 'unresolved series', prose, wrapPerGroup)
		trackingCats[#trackingCats + 1] = data.unresolvedSeriesCategory
	end
	if anyUnknownEpisode and isInMainspace() then
		trackingCats[#trackingCats + 1] = data.unknownEpisodeCategory
	end

	local joined = table.concat(outputs, ';&nbsp;')

	-- Single group: outputs[1] is already fully formed (prose-prefixed or
	-- fully wrapped, per wrapPerGroup above) — pass it through unchanged.
	-- Multiple groups in non-prose mode: outputs are bare "ABBR: body"
	-- fragments (wrapPerGroup was false), now joined with "; " and wrapped
	-- ONCE, e.g. "(1980: "X"; CAP: "Y")" instead of two separate
	-- adjacent parenthetical spans. Prose mode never wraps either way.
	if totalGroups <= 1 or prose then
		return joined, formatCats(allCategories, trackingCats)
	end
	return '<span class="source-citation"><sup>(' .. joined .. ')</sup></span>',
		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