Documentation for this module may be created at Module:IssueLink/doc
--------------------------------------------------
-- Module:IssueLink
-- Shared logic for {{last}}, {{next}}, {{between}}
--------------------------------------------------
local p = {}
--------------------------------------------------
-- Helpers
--------------------------------------------------
local function trim(s)
if not s then return "" end
s = s:gsub("^%s+", "")
s = s:gsub("%s+$", "")
return s
end
local function splitOnce(str, sep)
if not str then return nil, nil end
local startPos, endPos = str:find(sep, 1, true)
if not startPos then
return nil, nil
end
local left = str:sub(1, startPos - 1)
local right = str:sub(endPos + 1)
return left, right
end
local function getBase(frame)
local title = mw.title.getCurrentTitle()
local text = title.text or ""
-- Try: "<seriesPart> <issue>"
local seriesPart, issueStr = text:match("^(.-)%s+(%d+)$")
if not seriesPart then
-- Fallback: treat entire title as series with no issue
seriesPart = text
end
seriesPart = trim(seriesPart or "")
local baseTitle = seriesPart
local seriesSuffix = ""
-- Split base title vs "Vol. X ..." suffix if present
local t1, t2 = seriesPart:match("^(.-)%s+(Vol%.%s*%d+.*)$")
if t1 then
baseTitle = trim(t1)
seriesSuffix = trim(t2)
end
return {
seriesPart = seriesPart, -- e.g. "Green Lantern Vol. 4"
baseTitle = baseTitle, -- e.g. "Green Lantern"
seriesSuffix = seriesSuffix, -- e.g. "Vol. 4"
issue = tonumber(issueStr) or nil
}
end
local function unknownMarker()
return '<font color="#cc0000">???</font>[[Category:Chronology incomplete]]'
end
local function parseExtra(extra, spec)
extra = trim(extra or "")
if extra == "" then return end
-- Tokenize on ';' and ',' and whitespace.
-- Also supports compact forms like "pg5pan2" in a single token.
for token in extra:gmatch("[^;,%s]+") do
token = trim(token)
if token ~= "" then
local t = token:lower()
if t == "fb" then
spec.flashback = true
elseif t == "bts" then
spec.behind = true
end
-- Extract page/panel even if combined (e.g., "pg5pan2")
local pg = token:match("pg(%d+)")
local pan = token:match("pan(%d+)")
if pg then spec.page = tonumber(pg) end
if pan then spec.panel = tonumber(pan) end
end
end
end
local function splitMainAndExtra(s)
s = trim(s or "")
if s == "" then return "", nil end
-- First try the balanced-parens pattern
local main, paren = s:match("^(.-)%s*(%b())%s*$")
if paren then
return trim(main), paren:sub(2, -2)
end
-- Fallback: if there's a '(' and the string ends with ')', split there
local i = s:find("%(")
if i and s:sub(-1) == ")" then
return trim(s:sub(1, i - 1)), s:sub(i + 1, -2)
end
-- No extra block
return s, nil
end
--------------------------------------------------
-- Parsing: same-title shorthand
--------------------------------------------------
local function parseSameTitleSpec(raw, base)
local s = trim(raw)
if s == "" then return nil end
-- Strip optional "issue " and leading "#"
s = s:gsub("^[Ii]ssue%s+", "", 1)
s = s:gsub("^#", "", 1)
s = trim(s)
if s == "" then return nil end
-- Peel a trailing "(...)" if present, capturing extras
local extra = nil
local paren = s:match("%b()%s*$")
if paren then
extra = paren:sub(2, -2)
s = s:gsub("%s*%b()%s*$", "", 1) -- drop the final "(...)"
s = trim(s)
end
-- Accept "N" or "N/M"
local issueStr, storyStr = s:match("^(%d+)%s*/%s*(%d+)$")
local issue, story
if issueStr then
issue = tonumber(issueStr)
story = tonumber(storyStr)
else
issueStr = s:match("^(%d+)$")
if not issueStr then return nil end
issue = tonumber(issueStr)
end
local spec = {
seriesPart = base.seriesPart,
baseTitle = base.baseTitle,
seriesSuffix = base.seriesSuffix,
issue = issue,
story = story,
isSameTitle = true,
isExternal = false,
flashback = false,
behind = false,
page = nil,
panel = nil,
}
if extra and extra ~= "" then
parseExtra(extra, spec)
end
return spec
end
--------------------------------------------------
-- Parsing: explicit/other-title specs
--------------------------------------------------
local function parseExternalSpec(raw)
local s = trim(raw)
if s == "" then return nil end
-- Peel a trailing "(...)" if present, capturing extras
local extra = nil
local paren = s:match("%b()%s*$")
if paren then
extra = paren:sub(2, -2)
s = s:gsub("%s*%b()%s*$", "", 1)
s = trim(s)
end
-- From here on, 's' is the clean title/# part, and 'extra' holds fb/bts/pg/pan
-- If it contains "#", treat as numbered issue
if s:find("#", 1, true) then
local beforeHash, hashPart = s:match("^(.-)%s*#(.-)$")
if not beforeHash or beforeHash == "" or not hashPart or hashPart == "" then
return nil
end
local seriesPart = trim(beforeHash)
hashPart = trim(hashPart)
local issueStr, storyStr = hashPart:match("^(%d+)%s*/%s*(%d+)$")
local issue, story
if issueStr then
issue = tonumber(issueStr)
story = tonumber(storyStr)
else
issueStr = hashPart:match("^(%d+)$")
if not issueStr then return nil end
issue = tonumber(issueStr)
end
local baseTitle = seriesPart
local suffix = ""
local t1, t2 = seriesPart:match("^(.-)%s+(Vol%.%s*%d+.*)$")
if t1 then
baseTitle = trim(t1)
suffix = trim(t2)
end
local spec = {
seriesPart = seriesPart,
baseTitle = baseTitle,
seriesSuffix = suffix,
issue = issue,
story = story,
isSameTitle = false,
isExternal = true,
flashback = false,
behind = false,
page = nil,
panel = nil,
}
if extra and extra ~= "" then
parseExtra(extra, spec)
end
return spec
else
-- GN / unnumbered
local title = s
local baseTitle = title
local suffix = ""
local t1, t2 = title:match("^(.-)%s+(Vol%.%s*%d+.*)$")
if t1 then
baseTitle = trim(t1)
suffix = trim(t2)
end
local spec = {
seriesPart = title,
fullTitle = title,
baseTitle = baseTitle,
seriesSuffix = suffix,
issue = nil,
story = nil,
isSameTitle = false,
isExternal = true,
flashback = false,
behind = false,
page = nil,
panel = nil,
}
if extra and extra ~= "" then
parseExtra(extra, spec)
end
return spec
end
end
--------------------------------------------------
-- parseSpec: decides same-title vs explicit other-title
--------------------------------------------------
local function parseSpec(raw, base)
local s = trim(raw)
if s == "" then return nil end
-- Special "???" marker
if s == "???" then
return {
isUnknown = true
}
end
-- Same-title shorthand: starts with "issue ", "#", or digit
if s:match("^[Ii]ssue%s+%d") or s:match("^#%d") or s:match("^%d") then
return parseSameTitleSpec(s, base)
end
-- Otherwise, treat as explicit series name (may be numbered or GN)
return parseExternalSpec(s)
end
local function parseBetweenRightSpec(rightRaw, leftSpec, base)
rightRaw = trim(rightRaw or "")
if rightRaw == "" then
return nil
end
-- "issue 12" -> always current series
if rightRaw:match("^[Ii]ssue%s+") then
return parseSpec(rightRaw, base)
-- "#12" with explicit left series -> reuse left's series
elseif rightRaw:match("^#%d") and leftSpec.isExternal and leftSpec.seriesPart and leftSpec.seriesPart ~= "" then
local newRight = leftSpec.seriesPart .. " " .. rightRaw
return parseSpec(newRight, base)
-- "7/3(pg...)" with explicit left series -> reuse left's series
elseif rightRaw:match("^%d") and leftSpec.isExternal and leftSpec.seriesPart and leftSpec.seriesPart ~= "" then
local newRight = leftSpec.seriesPart .. " #" .. rightRaw
return parseSpec(newRight, base)
else
return parseSpec(rightRaw, base)
end
end
local function parseSingleBetweenSpec(raw, base)
raw = trim(raw or "")
local sep = " and "
local searchFrom = 1
-- First try an issue-aware split.
-- This avoids splitting inside titles such as "Brave and the Bold".
while true do
local startPos, endPos = raw:find(sep, searchFrom, true)
if not startPos then
break
end
local leftRaw = trim(raw:sub(1, startPos - 1))
local rightRaw = trim(raw:sub(endPos + 1))
local leftSpec = parseSpec(leftRaw, base)
if leftSpec and leftSpec.issue then
local rightSpec = parseBetweenRightSpec(rightRaw, leftSpec, base)
if rightSpec then
return leftSpec, rightSpec
end
end
searchFrom = endPos + 1
end
-- Fallback: preserve the old behavior for non-numbered / GN cases.
local leftRaw, rightRaw = splitOnce(raw, sep)
if not rightRaw then
return nil, nil
end
local leftSpec = parseSpec(leftRaw, base)
if not leftSpec then
return nil, nil
end
local rightSpec = parseBetweenRightSpec(rightRaw, leftSpec, base)
if not rightSpec then
return nil, nil
end
return leftSpec, rightSpec
end
--------------------------------------------------
-- Link + location rendering
--------------------------------------------------
local function buildLocation(spec)
local parts = {}
if spec.page then
table.insert(parts, "page " .. spec.page)
end
if spec.panel then
table.insert(parts, "panel " .. spec.panel)
end
if #parts == 0 then
return ""
else
return ", " .. table.concat(parts, ", ")
end
end
-- Build page title for a numbered issue, applying the Vol. 1 rule:
-- "Superman Vol. 1 #4" -> page "Superman 4"
-- "Superman Vol. 3 #4" -> page "Superman Vol. 3 4"
local function buildPageTitle(spec)
local seriesPart = trim(spec.seriesPart or "")
if seriesPart == "" or not spec.issue then
return nil
end
if spec.isExternal and spec.seriesSuffix == "Vol. 1" and spec.baseTitle then
-- Drop "Vol. 1" from the page title, keep baseTitle only
return trim(spec.baseTitle) .. " " .. spec.issue
else
return seriesPart .. " " .. spec.issue
end
end
local function makeLink(spec)
-- Unknown marker
if spec.isUnknown then
return unknownMarker()
end
-- Numbered issues
if spec.issue then
local page = buildPageTitle(spec)
if not page or page == "" then return "" end
if spec.story then
page = page .. "#" .. spec.story
end
local display
if spec.isSameTitle then
-- Same-title: just "#5", "#5/2"
display = "#" .. spec.issue
if spec.story then
display = display .. "/" .. spec.story
end
elseif spec.isExternal then
-- Other title: ''Series'' Vol. X #N
local titleText = "''" .. (spec.baseTitle or spec.seriesPart or "") .. "''"
if spec.seriesSuffix and spec.seriesSuffix ~= "" then
titleText = titleText .. " " .. spec.seriesSuffix
end
local numText = "#" .. spec.issue
if spec.story then
numText = numText .. "/" .. spec.story
end
display = titleText .. " " .. numText
else
-- Fallback
display = "#" .. spec.issue
if spec.story then
display = display .. "/" .. spec.story
end
end
return "[[" .. page .. "|" .. display .. "]]"
end
-- GN / unnumbered
local title = spec.fullTitle or spec.seriesPart or ""
title = trim(title)
if title == "" then return "" end
local titleText = "''" .. (spec.baseTitle or title) .. "''"
if spec.seriesSuffix and spec.seriesSuffix ~= "" then
titleText = titleText .. " " .. spec.seriesSuffix
end
return "[[" .. title .. "|" .. titleText .. "]]"
end
--------------------------------------------------
-- last / next shared helper
--------------------------------------------------
local function buildRelative(frame, isNext)
local parent = frame:getParent()
local args = parent and parent.args or frame.args
local raw = trim(args[1] or "")
local role = trim(args.role or args["role"] or "")
-- Special "???" marker: last/next in unknown
if raw == "???" then
local word = isNext and "next" or "last"
local phrase = word
if role ~= "" then
phrase = phrase .. " as " .. role
end
return phrase .. " in " .. unknownMarker()
end
local base = getBase(frame)
local spec
if raw == "" then
-- Default: previous/next issue of same title
if not base.issue then
return ""
end
local delta = isNext and 1 or -1
local targetIssue = base.issue + delta
if targetIssue < 1 then
return ""
end
spec = {
seriesPart = base.seriesPart,
baseTitle = base.baseTitle,
seriesSuffix = base.seriesSuffix,
issue = targetIssue,
story = nil,
isSameTitle = true,
isExternal = false,
flashback = false,
behind = false,
page = nil,
panel = nil,
}
else
spec = parseSpec(raw, base)
end
if not spec then return "" end
local link = makeLink(spec)
if link == "" then return "" end
local loc = buildLocation(spec)
local word = isNext and "next" or "last"
-- Build the prefix: "last", "last behind the scenes", "last behind the scenes as Robin"
local phrase = word
if spec.behind then
phrase = phrase .. ", behind the scenes,"
end
if role ~= "" then
phrase = phrase .. " as " .. role
end
-- Build the tail: " in issue ...", " in flashback in issue ...", etc.
local tail
if spec.flashback then
-- Same-title numbered issue → "in flashback in issue ..."
if spec.issue and spec.isSameTitle then
tail = " in flashback in issue " .. link .. loc
else
-- External or GN → no "issue"
tail = " in flashback in " .. link .. loc
end
else
-- Same-title numbered issue → "in issue ..."
if spec.issue and spec.isSameTitle then
tail = " in issue " .. link .. loc
else
-- External or GN → no "issue"
tail = " in " .. link .. loc
end
end
return phrase .. tail
end
function p.last(frame)
return buildRelative(frame, false)
end
function p.next(frame)
return buildRelative(frame, true)
end
--------------------------------------------------
-- between
--------------------------------------------------
function p.between(frame)
local parent = frame:getParent()
local args = parent and parent.args or frame.args
local base = getBase(frame)
local raw1 = trim(args[1] or "")
local raw2 = trim(args[2] or "")
local role = trim(args.role or args["role"] or "")
-- Global flashback flag for the whole "between" relation.
-- "fb" can be either the second unnamed param (for single-parameter "X and Y")
-- or the third unnamed param (for two-parameter "|left|right|fb").
local betweenFb = false
if raw2:lower() == "fb" then
betweenFb = true
raw2 = "" -- treat as no second spec parameter
end
local extra2 = trim(args[3] or "")
if extra2 ~= "" and extra2:lower() == "fb" then
betweenFb = true
end
local leftSpec, rightSpec
--------------------------------------------------
-- Special case: single-parameter panel-range syntax:
-- e.g. "Superman Vol. 3 #5(pg6,pan5&pan6)"
-- "Superman Vol. 3 #5(pg6,pan5&pg7,pan2)"
-- "Superman Vol. 3 #5(fb;pg6,pan5&pan6)"
--------------------------------------------------
if raw1 ~= "" and raw2 == "" then
local main, paren = raw1:match("^(.-)%s*(%b())%s*$")
if paren and paren:find("&", 1, true) then
main = trim(main or "")
local content = paren:sub(2, -2) -- strip parentheses
-- Optional "fb;" prefix inside the parentheses
local fb = false
local fbPart, rest = content:match("^(fb);(.*)$")
if fbPart then
fb = true
content = trim(rest or "")
end
-- Split two locations on "&"
local leftLocStr, rightLocStr = content:match("^(.-)&(.-)$")
if leftLocStr and rightLocStr then
-- Parse the base issue spec (same-title or external)
local baseSpec = parseSpec(main, base)
if not baseSpec then
return ""
end
if fb then
baseSpec.flashback = true
end
-- Parse a single "pgX,panY" / "pgX" / "panY" location,
-- optionally inheriting a default page (for the second half).
local function parseLoc(loc, defaultPage)
loc = trim(loc or "")
local pg = loc:match("pg(%d+)")
local pan = loc:match("pan(%d+)")
if not pg and defaultPage then
pg = defaultPage
end
return pg and tonumber(pg) or nil, pan and tonumber(pan) or nil
end
local pg1, pan1 = parseLoc(leftLocStr, nil)
local pg2, pan2 = parseLoc(rightLocStr, pg1)
local link = makeLink(baseSpec)
-- Build "page / panel" range text for the tail
local function buildRangeText()
-- Special nice case: two pages, no panels -> "pages 6 and 8"
if pg1 and pg2 and not pan1 and not pan2 then
return "pages " .. pg1 .. " and " .. pg2
end
-- Special nice case: same page and two panels -> "page 6, panels 5 and 6"
if pg1 and pg2 and pg1 == pg2 and pan1 and pan2 then
return "page " .. pg1 .. ", panels " .. pan1 .. " and " .. pan2
end
local segs = {}
if pg1 or pan1 then
local seg = ""
if pg1 then
seg = "page " .. pg1
end
if pan1 then
if seg ~= "" then
seg = seg .. ", panel " .. pan1
else
seg = "panel " .. pan1
end
end
table.insert(segs, seg)
end
if pg2 or pan2 then
local seg = ""
if pg2 then
seg = "page " .. pg2
end
if pan2 then
if seg ~= "" then
seg = seg .. ", panel " .. pan2
else
seg = "panel " .. pan2
end
end
table.insert(segs, seg)
end
if #segs == 0 then
return ""
elseif #segs == 1 then
return segs[1]
else
return segs[1] .. " and " .. segs[2]
end
end
local rangeText = buildRangeText()
-- Prefix: flashback + same-title vs external
local prefix
if baseSpec.flashback then
if baseSpec.isSameTitle and baseSpec.issue then
prefix = "in between flashback in issue " .. link
else
prefix = "in between flashback in " .. link
end
else
if baseSpec.isSameTitle and baseSpec.issue then
prefix = "in between issue " .. link
else
prefix = "in between " .. link
end
end
local text
if rangeText ~= "" then
text = prefix .. ", " .. rangeText
else
text = prefix
end
-- Apply global flashback + role
if betweenFb then
text = "in flashback " .. text
end
if role ~= "" then
text = "as " .. role .. " " .. text
end
return text
end
-- If we get here, we had parentheses with "&" but couldn't parse:
-- fall through to normal logic as a fallback.
end
end
--------------------------------------------------
-- Normal between logic
--------------------------------------------------
if raw1 == "" and raw2 == "" then
-- Default: N-1 and N+1 of same title
if not base.issue then
return ""
end
local leftIssue = base.issue - 1
local rightIssue = base.issue + 1
if leftIssue < 1 then
leftIssue = nil
end
if not leftIssue and not rightIssue then
return ""
end
if leftIssue then
leftSpec = {
seriesPart = base.seriesPart,
baseTitle = base.baseTitle,
seriesSuffix = base.seriesSuffix,
issue = leftIssue,
story = nil,
isSameTitle = true,
isExternal = false,
flashback = false,
page = nil,
panel = nil,
}
end
if rightIssue then
rightSpec = {
seriesPart = base.seriesPart,
baseTitle = base.baseTitle,
seriesSuffix = base.seriesSuffix,
issue = rightIssue,
story = nil,
isSameTitle = true,
isExternal = false,
flashback = false,
page = nil,
panel = nil,
}
end
elseif raw1 ~= "" and raw2 == "" then
-- Single parameter, expect "X and Y"
-- Uses an issue-aware split so titles containing "and" still work.
leftSpec, rightSpec = parseSingleBetweenSpec(raw1, base)
if not leftSpec or not rightSpec then
return ""
end
else
-- Two explicit parameters
if raw1 ~= "" then
leftSpec = parseSpec(raw1, base)
end
if raw2 ~= "" then
rightSpec = parseSpec(raw2, base)
end
end
if not leftSpec or not rightSpec then
return ""
end
-- Same external series? (e.g. "Teen Titans Vol. 3 #33 and #34")
local sameExternalSeries =
leftSpec.isExternal and rightSpec.isExternal and
leftSpec.seriesPart and rightSpec.seriesPart and
leftSpec.seriesPart == rightSpec.seriesPart
-- Build left link normally
local linkL = makeLink(leftSpec)
-- Build right link; if same external series, drop title in display
local linkR
if sameExternalSeries and rightSpec.issue then
local page = buildPageTitle(rightSpec)
if page and page ~= "" then
if rightSpec.story then
page = page .. "#" .. rightSpec.story
end
local display = "#" .. rightSpec.issue
if rightSpec.story then
display = display .. "/" .. rightSpec.story
end
linkR = "[[" .. page .. "|" .. display .. "]]"
else
linkR = makeLink(rightSpec)
end
else
linkR = makeLink(rightSpec)
end
if linkL == "" or linkR == "" then
return ""
end
local locL = buildLocation(leftSpec)
local locR = buildLocation(rightSpec)
local leftFb = leftSpec.flashback and true or false
local rightFb = rightSpec.flashback and true or false
local bothSameTitle = leftSpec.isSameTitle and rightSpec.isSameTitle
-- Is one of the sides the "main story" issue (current page), for flashback-between?
local currentIssue = base.issue
local leftIsCurrent = currentIssue and leftSpec.isSameTitle and leftSpec.issue == currentIssue or false
local rightIsCurrent = currentIssue and rightSpec.isSameTitle and rightSpec.issue == currentIssue or false
local text
-- Helper to describe one side (left or right)
local function describeSide(spec, link, loc)
-- Unknown marker: just return "???", no "issue"/"flashback" wording
if spec.isUnknown then
return link .. loc -- link is already the colored "???" marker
end
-- Main story case: same issue as the current page, non-flashback, and overall "between" is flashback
if betweenFb and currentIssue and spec.isSameTitle and spec.issue == currentIssue and not spec.flashback then
return "main story"
end
local seg
if spec.flashback then
-- Flashback side
seg = "flashback in "
if spec.isSameTitle and spec.issue then
seg = seg .. "issue "
end
seg = seg .. link .. loc
else
-- Normal (non-flashback) side
if spec.isSameTitle and spec.issue then
seg = "issue " .. link .. loc
else
seg = link .. loc
end
end
return seg
end
-- Special case: both non-flashback, same-title numbered issues
-- AND no page/panel info on either side
-- AND not a flashback-between involving the current issue as "main story"
-- → "in between issues [#x] and [#y]"
if not leftFb
and not rightFb
and bothSameTitle
and leftSpec.issue
and rightSpec.issue
and not leftSpec.page
and not leftSpec.panel
and not rightSpec.page
and not rightSpec.panel
and not (betweenFb and (leftIsCurrent or rightIsCurrent))
then
text = "in between issues " .. linkL .. locL .. " and " .. linkR .. locR
else
-- All other combinations (cross-title, GN, any flashback,
-- or when page/panel info is present, or main story involvement)
local descL = describeSide(leftSpec, linkL, locL)
local descR = describeSide(rightSpec, linkR, locR)
text = "in between " .. descL .. " and " .. descR
end
-- Wrap with global flashback + role
if betweenFb then
text = "in flashback " .. text
end
if role ~= "" then
text = "as " .. role .. " " .. text
end
return text
end
return p