Module:Timeline

Documentation for this module may be created at Module:Timeline/doc

local p = {}
-- Cache for page existence checks
local existenceCache = {}

-- This checks if a wiki page for a given year exists.
local function isValidYearPage(key)
	local title = mw.title.new(key)
	if not title or not title.exists then
		return false
	end
	return true
end

local function pageExists(year)
	local key = tostring(year)
	if existenceCache[key] ~= nil then
		return existenceCache[key]
	end
	local exists = isValidYearPage(key)
	existenceCache[key] = exists
	return exists
end

function p.main(frame)
	local args = frame:getParent().args
	local title = mw.title.getCurrentTitle().text

	-- Detect year and era (Example: 2552, 480 BCE, so on)
	local yearStr = args.year or title
	local yearNum, era

	-- This checks if the year is BCE or CE
	if yearStr:match("%d+%s*BCE$") then
		yearNum = tonumber(yearStr:match("%d+"))
		era = "BCE"
	else
		yearNum = tonumber(yearStr:match("%d+")) or 0
		era = "CE"
	end

	-- This stops 0 from being used.
	if yearNum == 0 then
		return '<!-- Invalid year -->'
	end

	-- Parse ignore list
	local ignoreSet = {}
	if args.ignore and args.ignore ~= '' then
		for y in mw.text.gsplit(args.ignore, ",", true) do
			y = mw.text.trim(y)
			local num = tonumber(y:match("%d+"))
			if num then ignoreSet[num] = true end
		end
	end

	-- Use this to style the template.
	local html = mw.html.create('div')
		:addClass('infobox')
		:css({
			float = 'right',
			width = '300px',
			margin = '0 0 1em 1em',
			padding = '8px',
			border = '1px solid #aaa',
			background = '#f9f9f9',
			['border-radius'] = '4px'
		})

	-- Timeline Logo Header
	local logoClass = ''
	if args.image and args.image ~= '' then
		logoClass = 'notpageimage'
	end

	-- Year Header
	local displayYear = (era == "BCE") and (yearNum .. " BCE") or tostring(yearNum)
	html:tag('div')
		:addClass('infobox-header')
		:css('text-align', 'center')
		:css('font-weight', 'bold')
		:css('font-size', '200%')
		:wikitext(displayYear)

	-- Year image + caption
	if args.image and args.image ~= '' then
		html:tag('div')
			:css('text-align', 'center')
			:css('margin', '10px 0 6px 0')
			:wikitext('[[' .. args.image .. '|300px]]')

		if args.caption and args.caption ~= '' then
			html:tag('div')
				:css('text-align', 'center')
				:css('font-size', '85%')
				:css('margin-top', '4px')
				:wikitext(args.caption)
		end
	end

	-- Other calendars
	if args.other and args.other ~= '' then
		html:tag('div')
			:css('margin-top', '10px')
			:css('text-align', 'center')
			:wikitext(args.other)
	end

	-- Override code - CIA note: This is needed for years so stuff doesnt break with timespans that go beyond the capabilities of Mediawiki.
	local manual = {
		previous1 = tonumber(args.previous1 or args.manualprevious1),
		previous2 = tonumber(args.previous2 or args.manualprevious2),
		next1     = tonumber(args.next1     or args.manualnext1),
		next2     = tonumber(args.next2     or args.manualnext2)
	}

	-- Helper to check if we're near the BCE/CE boundary (only affects ~199 BCE and ~199 CE)
	local function isNearEraTransition(year)
		return year <= 200
	end

	local function findNearestPrev(year, currentEra)
		local maxSearch = 200
		if currentEra == "BCE" then
			local y = year + 1
			for _ = 1, maxSearch do
				if y > 3000 then break end
				if y ~= year and not ignoreSet[y] and pageExists(y .. " BCE") then
					return y, "BCE"
				end
				y = y + 1
			end
		else
			local y = year - 1
			for _ = 1, maxSearch do
				if y < 1 then break end
				if y ~= year and not ignoreSet[y] and pageExists(tostring(y)) then
					return y, "CE"
				end
				y = y - 1
			end
		end

		-- Only cross to BCE when near the boundary AND we are in CE
		if currentEra == "CE" and isNearEraTransition(year) then
			local y = 1
			for _ = 1, maxSearch do
				if y > 3000 then break end
				if y ~= year and not ignoreSet[y] and pageExists(y .. " BCE") then
					return y, "BCE"
				end
				y = y + 1
			end
		end
		return nil
	end

	local function findNearestNext(year, currentEra)
		local maxSearch = 200
		if currentEra == "BCE" then
			local y = year - 1
			for _ = 1, maxSearch do
				if y < 1 then break end
				if y ~= year and not ignoreSet[y] and pageExists(y .. " BCE") then
					return y, "BCE"
				end
				y = y - 1
			end
		else
			local y = year + 1
			for _ = 1, maxSearch do
				if y > 3000 then break end
				if y ~= year and not ignoreSet[y] and pageExists(tostring(y)) then
					return y, "CE"
				end
				y = y + 1
			end
		end

		-- Only cross to CE when near the boundary AND we are in BCE
		if currentEra == "BCE" and isNearEraTransition(year) then
			local y = 1
			for _ = 1, maxSearch do
				if y > 3000 then break end
				if y ~= year and not ignoreSet[y] and pageExists(tostring(y)) then
					return y, "CE"
				end
				y = y + 1
			end
		end
		return nil
	end

	local function makeLink(y, text, isBold, targetEra)
		if not y then return nil end
		local page = (targetEra == "BCE") and (y .. " BCE") or tostring(y)
		local display = (targetEra == "BCE") and (y .. " BCE") or tostring(y)
		if text then display = text end
		local link = '[[' .. page .. '|' .. display .. ']]'
		return isBold and "'''" .. link .. "'''" or link
	end

	-- === SMART NAVIGATION (THIS SECTION SUCKS OH MY GOONDESS) ===
	local nav = html:tag('div')
		:css('margin-top', '12px')
		:css('text-align', 'center')
		:css('font-size', '110%')

	local function getChain(directionFunc, startYear, startEra, count)
		local results = {}

		local currentYear = startYear
		local currentEra = startEra

		for _ = 1, count do
			local y, e = directionFunc(currentYear, currentEra)

			if not y then
				break
			end

			table.insert(results, {
				year = y,
				era = e
			})

			currentYear = y
			currentEra = e
		end

		return results
	end

	local prevYears = getChain(findNearestPrev, yearNum, era, 2)
	local nextYears = getChain(findNearestNext, yearNum, era, 2)

	-- Manual overrides
	if manual.previous1 then
		prevYears[2] = {
			year = manual.previous1,
			era = era
		}
	end

	if manual.previous2 then
		prevYears[1] = {
			year = manual.previous2,
			era = era
		}
	end

	if manual.next1 then
		nextYears[1] = {
			year = manual.next1,
			era = era
		}
	end

	if manual.next2 then
		nextYears[2] = {
			year = manual.next2,
			era = era
		}
	end

	local navTable = nav:tag('table')
		:css('width', '100%')
		:css('border-collapse', 'collapse')
		:css('table-layout', 'fixed')

	local row = navTable:tag('tr')

	-- Left side (previous years)
	local leftCell = row:tag('td')
		:css('text-align', 'right')
		:css('vertical-align', 'middle')
		:css('width', '40%')
		:css('padding-right', '10px')

	local leftParts = {}

	for i = #prevYears, 1, -1 do
		local item = prevYears[i]

		table.insert(
			leftParts,
			makeLink(item.year, nil, false, item.era)
		)
	end

	leftCell:wikitext(table.concat(leftParts, ' <span style="color:#777;">•</span> '))

	-- Center (current year - bold, no link)
	local centerCell = row:tag('td')
		:css('text-align', 'center')
		:css('font-weight', 'bold')
		:css('vertical-align', 'middle')
		:css('width', '20%')
		:css('white-space', 'nowrap')
		:css('font-size', '115%')

	centerCell:wikitext(displayYear)

	-- Right side (next years)
	local rightCell = row:tag('td')
		:css('text-align', 'left')
		:css('vertical-align', 'middle')
		:css('width', '40%')
		:css('padding-left', '10px')

	local rightParts = {}

	for _, item in ipairs(nextYears) do
		table.insert(
			rightParts,
			makeLink(item.year, nil, false, item.era)
		)
	end

	rightCell:wikitext(table.concat(rightParts, ' <span style="color:#777;">•</span> '))

	-- Decade Grid with 0s exception
	local decadeStart = math.floor(yearNum / 10) * 10
	local decadeLabel = tostring(decadeStart) .. "s"
	if era == "BCE" then decadeLabel = decadeLabel .. " BCE" end

	local decadeDiv = html:tag('div')
		:css('margin-top', '14px')
		:css('text-align', 'center')
		:css('font-size', '110%')
		:css('line-height', '1.8')

	decadeDiv:wikitext("'''Years in the " .. decadeLabel .. "'''<br>")

	local firstRowStart = (decadeStart == 0 and era == "CE") and 1 or 0
	for i = firstRowStart, 4 do
		local y = decadeStart + i
		local text = tostring(y) .. (era == "BCE" and " BCE" or "")
		local link
		if y == yearNum then
			link = "'''" .. text .. "'''"
		elseif pageExists((era == "BCE") and (y .. " BCE") or tostring(y)) then
			link = makeLink(y, text, false, era)
		else
			link = '<span style="color:#888">' .. text .. '</span>'
		end
		decadeDiv:wikitext(link)
		if i < 4 then decadeDiv:wikitext(' • ') end
	end
	decadeDiv:wikitext('<br>')

	for i = 5, 9 do
		local y = decadeStart + i
		local text = tostring(y) .. (era == "BCE" and " BCE" or "")
		local link
		if y == yearNum then
			link = "'''" .. text .. "'''"
		elseif pageExists((era == "BCE") and (y .. " BCE") or tostring(y)) then
			link = makeLink(y, text, false, era)
		else
			link = '<span style="color:#888">' .. text .. '</span>'
		end
		decadeDiv:wikitext(link)
		if i < 9 then decadeDiv:wikitext(' • ') end
	end

	-- Footer
	html:tag('div')
		:css('margin-top', '12px')
		:css('text-align', 'center')
		:css('font-size', '85%')
		:wikitext("For a complete list, see the [[:Category:Timeline|timeline category]].")

	return tostring(html)
end

return p