Module:Category series navigation

Documentation for this module may be created at Module:Category series navigation/doc

require('strict')
local p = {}
local horizontal = require('Module:List').horizontal

--[[==========================================================================]]
--[[                                Globals                                   ]]
--[[==========================================================================]]

local currtitle = mw.title.getCurrentTitle()
local nexistingcats = 0
local errors = ''
local testcasecolon = ''
local testcases = string.match(currtitle.subpageText, '^testcases')
if    testcases then testcasecolon = ':' end
local navborder = true
local followRs = true
local skipgaps = false
local skipgaps_limit = 50
local term_limit = 10
local hgap_limit = 6
local ygap_limit = 5
local listall = false
local tlistall = {}
local tlistallbwd = {}
local tlistallfwd = {}
local ttrackingcats = { --when reindexing, Ctrl+H 'trackcat(13,' & 'ttrackingcats[16]'
	'', -- [1] placeholder for [[Category:Category series navigation using cat parameter]]
	'', -- [2] placeholder for [[Category:Category series navigation using testcase parameter]]
	'', -- [3] placeholder for [[Category:Category series navigation using unknown parameter]]
	'', -- [4] placeholder for [[Category:Category series navigation range not using en dash]]
	'', -- [5] placeholder for [[Category:Category series navigation range abbreviated (MOS)]]
	'', -- [6] placeholder for [[Category:Category series navigation range redirected (base change)]]
	'', -- [7] placeholder for [[Category:Category series navigation range redirected (var change)]]
	'', -- [8] placeholder for [[Category:Category series navigation range redirected (end)]]
	'', -- [9] placeholder for [[Category:Category series navigation range redirected (MOS)]]
	'', --[10] placeholder for [[Category:Category series navigation range redirected (other)]]
	'', --[11] placeholder for [[Category:Category series navigation range gaps]]
	'', --[12] placeholder for [[Category:Category series navigation range irregular]]
	'', --[13] placeholder for [[Category:Category series navigation range irregular, 0-length]]
	'', --[14] placeholder for [[Category:Category series navigation range ends (present)]]
	'', --[15] placeholder for [[Category:Category series navigation range ends (blank, MOS)]]
	'', --[16] placeholder for [[Category:Category series navigation isolated]]
	'', --[17] placeholder for [[Category:Category series navigation default season gap size]]
	'', --[18] placeholder for [[Category:Category series navigation decade redirected]]
	'', --[19] placeholder for [[Category:Category series navigation year redirected (base change)]]
	'', --[20] placeholder for [[Category:Category series navigation year redirected (var change)]]
	'', --[21] placeholder for [[Category:Category series navigation year redirected (other)]]
	'', --[22] placeholder for [[Category:Category series navigation roman numeral redirected]]
	'', --[23] placeholder for [[Category:Category series navigation nordinal redirected]]
	'', --[24] placeholder for [[Category:Category series navigation wordinal redirected]]
	'', --[25] placeholder for [[Category:Category series navigation TV season redirected]]
	'', --[26] placeholder for [[Category:Category series navigation using skip-gaps parameter]]
	'', --[27] placeholder for [[Category:Category series navigation year and range]]
	'', --[28] placeholder for [[Category:Category series navigation year and decade]]
	'', --[29] placeholder for [[Category:Category series navigation decade and century]]
	'', --[30] placeholder for [[Category:Category series navigation in mainspace]]
	'', --[31] placeholder for [[Category:Category series navigation redirection error]]
}
local avoidself =  (not string.match(currtitle.text, 'Category series navigation with') and
					not string.match(currtitle.text, 'Category series navigation.*/doc') and
					not string.match(currtitle.text, 'Category series navigation.*/sandbox') and
					currtitle.text ~= 'Category series navigation' and
					currtitle.nsText:gsub('_', ' ') ~= 'User talk' and -- [[phab:T369784]]
					currtitle.nsText:gsub('_', ' ') ~= 'Template talk' and
					(currtitle.nsText ~= 'Template' or testcases)) --avoid nested transclusion errors (i.e. {{Infilmdecade}})


--[[==========================================================================]]
--[[                      Utility & category functions                        ]]
--[[==========================================================================]]

--Determine if a category exists (in a function for easier localization).
local function catexists( title )
	return mw.title.new( title, 'Category' ).exists
end

--Error message handling.
function p.errorclass( msg )
	return mw.text.tag( 'span', {class='error mw-ext-cite-error'}, '<b>Error!</b> '..string.gsub(msg, '&#', '&amp;#') )
end

--Failure handling.
function p.failedcat( errors, sortkey )
	if avoidself then
		return (errors or '')..'&#42;&#42;&#42;Category series navigation failed to generate navbox***'..
			   '[['..testcasecolon..'Category:Category series navigation failed to generate navbox|'..(sortkey or 'O')..']]\n'
	end
	return ''
end

--Tracking cat handling.
--	key: 15 (when reindexing ttrackingcats{}, Ctrl+H 'trackcat(13,' & 'ttrackingcats[16]')
--	cat: 'Category series navigation isolated'; '' to remove
--Used by main, all nav_*(), & several utility functions.
local function trackcat( key, cat )
	if avoidself and key and cat then
		if cat ~= '' then
			ttrackingcats[key] = '[['..testcasecolon..'Category:'..cat..']]'
		else
			ttrackingcats[key] = ''
		end
	end
	return
end

--Check for unknown parameters.
--Used by main only.
local function checkforunknownparams( tbl )
	local knownparams = { --parameter whitelist
		['min'] = 'min',
		['max'] = 'max',
		['cat'] = 'cat',
		['show'] = 'show',
		['testcase'] = 'testcase',
		['testcasegap'] = 'testcasegap',
		['skip-gaps'] = 'skip-gaps',
		['list-all-links'] = 'list-all-links',
		['follow-redirects'] = 'follow-redirects',
	}
	for k, _ in pairs (tbl) do
		if knownparams[k] == nil then
			trackcat(3, 'Category series navigation using unknown parameter')
			break
		end
	end
end

--Check for nav_*() navigational isolation (not necessarily an error).
--Used by all nav_*().
local function isolatedcat()
	if nexistingcats == 0 then
		trackcat(16, 'Category series navigation isolated')
	end
end

--Returns the target of {{Category redirect}}, if it exists, else returns the original cat.
--{{Title year}}, etc., if found, are evaluated.
--Used by catlinkfollowr(), and so indirectly by all nav_*().
local function rtarget( frame, cat )
	local catcontent = mw.title.new( cat or '', 'Category' ):getContent()
	if string.match( catcontent or '', '{{ *[Cc]at' ) then --prelim test
		local getRegex = require('Module:Template redirect regex').main
		local tregex = getRegex('Category redirect')
		for _, v in pairs (tregex) do
			local rtarget = mw.ustring.match( catcontent, v..'%s*|%s*([^|}]+)' )
			if rtarget then
				if string.match(rtarget, '{{') then --{{Title year}}, etc., exists; evaluate
					local regex_ty = '%s*|%s*([^{}]*{{([^{|}]+)}}[^{}]-)%s*}}' --eval null-param templates only; expanded if/as needed
					local rtarget_orig, ty = mw.ustring.match( catcontent, v..regex_ty )
					if rtarget_orig then
						local ty_eval = frame:expandTemplate{ title = ty, args = { page = cat } } --frame:newChild doesn't work, use 'page' param instead
						local rtarget_eval = mw.ustring.gsub(rtarget_orig, '{{%s*'..ty..'%s*}}', ty_eval )
						return rtarget_eval
					else --sub-parameters present; track & return default
						trackcat(31, 'Category series navigation redirection error')
					end
				end
				rtarget = mw.ustring.gsub(rtarget, '^1%s*=%s*', '')
				rtarget = string.gsub(rtarget, '^[Cc]ategory:', '')
				return rtarget
			end
		end --for
	end --if
	return cat
end

--Similar to {{LinkCatIfExists2}}: make a piped link to a category, if it exists;
--if it doesn't exist, just display the greyed link title without linking.
--Follows {{Category redirect}}s.
--Returns {
--			['cat'] = cat,
--			['catexists'] = true,
--			['rtarget'] = <#R target>,
--			['navelement'] = <#R target navelement>,
--			['displaytext'] = displaytext,
--		  }
--		  if #R followed;
--returns {
--			['cat'] = cat,
--			['catexists'] = <true|false>,
--			['rtarget'] = nil,
--			['navelement'] = <cat navelement>,
--			['displaytext'] = displaytext,
--		  }
--		  otherwise.
--Used by all nav_*().
local function catlinkfollowr( frame, cat, displaytext, displayend, listoverride )
	cat         = mw.text.trim(cat or '')
	displaytext = mw.text.trim(displaytext or '')
	displayend  = displayend or false --bool flag to override displaytext IIF the cat/target is terminal (e.g. "2021–present" or "2021–")
	
	local disp = cat
	if displaytext ~= '' then --use 'displaytext' parameter if present
		disp = mw.ustring.gsub(displaytext, '%s+%(.+$', ''); --strip any trailing disambiguator
	end
	
	local link, nilorR
	local exists = catexists(cat)
	if exists then
		nexistingcats = nexistingcats + 1
		if followRs then
			local R = rtarget(frame, cat) --find & follow #R
			if R ~= cat then --#R followed
				nilorR = R
			end
			
			if displayend then
				local y, hyph, ending = mw.ustring.match(R, '^.-(%d+)([–-])(.*)$')
				if ending == 'present' then
					disp = y..hyph..ending
				elseif ending == '' then
					disp = y..hyph..'<span style="visibility:hidden">'..y..'</span>' --hidden y to match spacing
				end
			end
			
			link = '[[:Category:'..R..'|'..disp..']]'
		else
			link = '[[:Category:'..cat..'|'..disp..']]'
		end
	else
		link = '<span class="categorySeriesNavigation-item-inactive">'..disp..'</span>'
	end
	
	if listall and listoverride == nil then
		if nilorR then --#R followed
			table.insert( tlistall, '[[:Category:'..cat..']] → '..'[[:Category:'..nilorR..']] ('..link..')' )
		else --no #R
			table.insert( tlistall, '[[:Category:'..cat..']] ('..link..')' )
		end
	end
	
	return {
		['cat'] = cat,
		['catexists'] = exists,
		['rtarget'] = nilorR,
		['navelement'] = link,
		['displaytext'] = disp,
	}
end

--Returns a numbered list of all {{Category redirect}}s followed by catlinkfollowr() -> rtarget().
--For a nav_hyphen() cat, also returns a formatted list of all cats searched for & found, & all loop indices.
--Used by all nav_*().
local function listalllinks()
	local nl = '\n# '
	local out = ''
	if currtitle.nsText == 'Category' then
		errors = p.errorclass('The <b><code>|list-all-links=yes</code></b> parameter/utility '..
							'should not be saved in category space, only previewed.')
		out = p.failedcat(errors, 'Z')
	end
	
	local bwd, fwd = '', ''
	if tlistallbwd[1] then
		bwd = '\n\nbackward search:'..nl..table.concat(tlistallbwd, nl)
	end
	if tlistallfwd[1] then
		fwd = '\n\nforward search:'..nl..table.concat(tlistallfwd, nl)
	end
	
	if tlistall[1] then
		return out..nl..table.concat(tlistall, nl)..bwd..fwd
	else
		return out..nl..'No links found!?'..bwd..fwd
	end
end

--Returns the difference b/w 2 ints separated by endash|hyphen, nil if error.
--Used by nav_hyphen() only.
local function find_duration( cat )
	local from, to = mw.ustring.match(cat, '(%d+)[–-](%d+)')
	if from and to then
		if to == '00' then return nil end --doesn't follow MOS:DATERANGE
		if (#from == 4) and (#to == 2) then             --1900-01
			to = string.match(from, '(%d%d)%d%d')..to   --1900-1901
		elseif (#from == 2) and (#to == 4) then         --  01-1902
			from = string.match(to, '(%d%d)%d%d')..from --1901-1902
		end
		return (tonumber(to) - tonumber(from))
	end
	return 0
end

--Returns the ending of a terminal cat, and sets the appropriate tracking cat, else nil.
--Used by nav_hyphen() only.
local function find_terminaltxt( cat )
	local terminaltxt = nil
	if mw.ustring.match(cat, '%d+[–-]present$') then
		terminaltxt = 'present'
		trackcat(14, 'Category series navigation range ends (present)')
	elseif mw.ustring.match(cat, '%d+[–-]$') then
		terminaltxt = ''
		trackcat(15, 'Category series navigation range ends (blank, MOS)')
	end
	return terminaltxt
end

--Returns an unsigned string of the 1-4 digit decade ending in "0", else nil.
--Used by nav_decade() only.
local function sterilizedec( decade )
	if decade == nil or decade == '' then
		return nil
	end
	
	local dec = string.match(decade, '^[-%+]?(%d?%d?%d?0)$') or
				string.match(decade, '^[-%+]?(%d?%d?%d?0)%D')
	if dec then
		return dec
	else
		--fix 2-4 digit decade
		local decade_fixed234 = string.match(decade, '^[-%+]?(%d%d?%d?)%d$') or
								string.match(decade, '^[-%+]?(%d%d?%d?)%d%D')
		if decade_fixed234 then
			return decade_fixed234..'0'
		end
		
		--fix 1-digit decade
		local decade_fixed1   = string.match(decade, '^[-%+]?(%d)$') or
								string.match(decade, '^[-%+]?(%d)%D')
		if decade_fixed1 then
			return '0'
		end
		
		--unfixable
		return nil
	end
end

--Check for nav_hyphen default gap size + isolatedcat() (not necessarily an error).
--Used by nav_hyphen() only.
local function defaultgapcat( bool )
	if bool and nexistingcats == 0 then
		--using "nexistingcats > 0" isn't as useful, since the default gap size obviously worked
		trackcat(17, 'Category series navigation default season gap size')
	end
end

--12 -> 12th, etc.
--Used by nav_nordinal() & nav_wordinal().
function p.addord( i )
	if tonumber(i) then
		local s = tostring(i)
		
		local tens = string.match(s, '1%d$')
		if    tens then return s..'oo' end
		
		local  ones = string.match(s, '%d$')
		if     ones == '1' then return s..'d'
		elseif ones == '2' then return s..'h' end
		
		return s..'oo'
	end
	return i
end

--Returns the properly formatted central nav element.
--Expects an integer i, and a catlinkfollowr() table.
--Used by nav_decade() & nav_ordinal() only.
local function navcenter( i, catlink )
	if i == 0 then --center nav element
		if navborder == true then
			return '<b>'..catlink.displaytext..'</b>'
		else
			return '<b>'..catlink.navelement..'</b>'
		end
	else
		return catlink.navelement
	end
end

--Wrap one or two navs in a <div> with ARIA attributes; add TemplateStyles
--before it. This also aligns the navs in case some floating element (like a
--portal box) breaks their alignment.
--Used by main only.
local function wrap( nav1, nav2 )
	local templatestyles = require("Module:TemplateStyles")(
		"Module:Category series navigation/styles.css"
	)
	local prepare = function (nav)
		if nav then
			nav = '\n'..nav
		else
			nav = ''
		end
		return nav
	end
	return templatestyles..
		'<div class="categorySeriesNavigation" role="navigation" aria-label="Range">'..
		prepare(nav1)..prepare(nav2)..
		'\n</div>'
end


--[[==========================================================================]]
--[[                  Formerly separated templates/modules                    ]]
--[[==========================================================================]]


--[[==========================={{  nav_hyphen  }}=============================]]

local function nav_hyphen( frame, start, hyph, finish, firstpart, lastpart, minseas, maxseas, testgap )
	--Expects a PAGENAME of the form "Some sequential 2015–16 example cat", where
	--	start     = 2015
	--	hyph      = –
	--	finish    = 16 (sequential years can be abbreviated, but others should be full year, e.g. "2001–2005")
	--	firstpart = Some sequential
	--	lastpart  = example cat
	--	minseas   = 1800 ('min' starting season shown; optional; defaults to -9999)
	--	maxseas   = 2000 ('max' starting season shown; optional; defaults to 9999; 2000 will show 2000-01)
	--	testgap   = 0 (testcasegap parameter for easier testing; optional)
	
	--sterilize start
	if string.match(start or '', '^%d%d?%d?%d?$') == nil then --1-4 digits, AD only
		local start_fixed = mw.ustring.match(start or '', '^%s*(%d%d?%d?%d?)%D')
		if start_fixed then
			start = start_fixed
		else
			errors = p.errorclass('Function nav_hyphen can\'t recognize the number "'..(start or '')..'" '..
								  'in the first part of the "season" that was passed to it. '..
								  'For e.g. "2015–16", "2015" is expected via "|2015|–|16|".')
			return p.failedcat(errors, 'H')
		end
	end
	local nstart = tonumber(start)
	
	--en dash check
	if hyph ~= '–' then
		trackcat(4, 'Category series navigation range not using en dash') --nav still processable, but track
	end
	
	--sterilize finish & check for weird parents
	local tgaps   = {} --table of gap sizes found b/w terms    { [<gap size found>]    = 1 } for -3 <= j <= 3
	local tgapsj4 = {} --table of gap sizes found b/w terms    { [<gap size found>]    = 1 } for j = { -4, 4 }
	local ttlens  = {} --table of term lengths found w/i terms { [<term length found>] = 1 }
	local tirregs = {} --table of ir/regular-term-length cats' "from"s & "to"s found
	local regularparent = true
	if (finish == -1) or --"Members of the Scottish Parliament 2021–present"
	   (finish == 0)	 --"Members of the Scottish Parliament 2021–"
	then
		regularparent = false
		if maxseas == nil or maxseas == '' then
			maxseas = start --hide subsequent ranges
		end
		if finish == -1 then trackcat(14, 'Category series navigation range ends (present)')
		else				 trackcat(15, 'Category series navigation range ends (blank, MOS)') end
	elseif (start == finish) and
		   (ttrackingcats[16] ~= '') --nav_year found isolated; check for surrounding hyphenated terms (e.g. UK MPs 1974)
	then
		trackcat(16, '') --reset for another check later
		trackcat(13, 'Category series navigation range irregular, 0-length')
		ttlens[0] = 1 --calc ttlens for std cases below
		regularparent = 'isolated'
	end
	if (string.match(finish or '', '^%d+$') == nil) and
	   (string.match(finish or '', '^%-%d+$') == nil)
	then
		local finish_fixed = mw.ustring.match(finish or '', '^%s*(%d%d?%d?%d?)%D')
		if finish_fixed then
			finish = finish_fixed
		else
			errors = p.errorclass('Function nav_hyphen can\'t recognize "'..(finish or '')..'" '..
								  'in the second part of the "season" that was passed to it. '..
								  'For e.g. "2015–16", "16" is expected via "|2015|–|16|".')
			return p.failedcat(errors, 'I')
		end
	else
		if string.len(finish) >= 5 then
			errors = p.errorclass('The second part of the season passed to function nav_hyphen should only be four or fewer digits, not "'..(finish or '')..'". '..
								  'See [[MOS:DATERANGE]] for details.')
			return p.failedcat(errors, 'J')
		end
	end
	local nfinish = tonumber(finish)
	
	--save sterilized parent range for easier lookup later
	tirregs['from0'] = nstart
	tirregs['to0']   = nfinish
	
	--sterilize min/max
	local nminseas_default = -9999
	local nmaxseas_default =  9999
	local nminseas = tonumber(minseas) or nminseas_default --same behavior as nav_year
	local nmaxseas = tonumber(maxseas) or nmaxseas_default --same behavior as nav_year
	if nminseas > nstart then nminseas = nstart end
	if nmaxseas < nstart then nmaxseas = nstart end
	
	local lspace = ' ' --assume a leading space (most common)
	local tspace = ' ' --assume a trailing space (most common)
	if string.match(firstpart, '%($') then lspace = '' end --DNE for "Madrid city councillors (2007–2011)"-type cats
	if string.match(lastpart,  '^%)') then tspace = '' end --DNE for "Madrid city councillors (2007–2011)"-type cats
	
	--calculate term length/intRAseason size & finishing year
	local t = 1
	while t <= term_limit and regularparent == true do
		local nish = nstart + t --use switchADBC to flip this sign to work for years BC, if/when the time comes
		if (nish == nfinish) or (string.match(nish, '%d?%d$') == finish) then
			ttlens[t] = 1
			break
		end
		if t == term_limit then
			errors = p.errorclass('Function nav_hyphen can\'t determine a reasonable term length for "'..start..hyph..finish..'".')
			return p.failedcat(errors, 'K')
		end
		t = t + 1
	end
	
	--apply MOS:DATERANGE to parent
	local lenstart = string.len(start)
	local lenfinish = string.len(finish)
	if lenstart == 4 and regularparent == true then --"2001–..."
		if t == 1 then --"2001–02" & "2001–2002" both allowed
			if lenfinish ~= 2 and lenfinish ~= 4 then
				errors = p.errorclass('The second part of the season passed to function nav_hyphen should be two or four digits, not "'..finish..'".')
				return p.failedcat(errors, 'L')
			end
		else --"2001–2005" is required for t > 1; track "2001–05"; anything else = error
			if lenfinish == 2 then
				trackcat(5, 'Category series navigation range abbreviated (MOS)')
			elseif lenfinish ~= 4 then
				errors = p.errorclass('The second part of the season passed to function nav_hyphen should be four digits, not "'..finish..'".')
				return p.failedcat(errors, 'M')
			end
		end
		if finish == '00' then --full year required regardless of term length
			trackcat(5, 'Category series navigation range abbreviated (MOS)')
		end
	end
	
	--calculate intERseason gap size
	local hgap_default     = 0 --assume & start at the most common case: 2001–02 -> 2002–03, etc.
	local hgap_limit_reg   = hgap_limit --less expensive per-increment (inc x 4)
	local hgap_limit_irreg = hgap_limit --more expensive per-increment (inc x 23 = inc x (k_bwd + k_fwd) = inc x (12 + 11))
	local hgap_success = false
	local hgap = hgap_default
	while hgap <= hgap_limit_reg and regularparent == true do --verify
		local prevseason2 = firstpart..lspace..(nstart-t-hgap)..hyph..string.match(nstart-hgap, '%d?%d$')    ..tspace..lastpart
		local nextseason2 = firstpart..lspace..(nstart+t+hgap)..hyph..string.match(nstart+2*t+hgap, '%d?%d$')..tspace..lastpart
		local prevseason4 = firstpart..lspace..(nstart-t-hgap)..hyph..(nstart-hgap)    ..tspace..lastpart
		local nextseason4 = firstpart..lspace..(nstart+t+hgap)..hyph..(nstart+2*t+hgap)..tspace..lastpart
		if t == 1 then --test abbreviated range first, then full range, to be frugal with expensive functions
			if catexists(prevseason2) or --use 'or', in case we're at the edge of the cat structure,
			   catexists(nextseason2) or --or we hit a "–00"/"–2000" situation on one side
			   catexists(prevseason4) or
			   catexists(nextseason4)
			then
				hgap_success = true
				break
			end
		elseif t > 1 then --test full range first, then abbreviated range, to be frugal with expensive functions
			if catexists(prevseason4) or --use 'or', in case we're at the edge of the cat structure,
			   catexists(nextseason4) or --or we hit a "–00"/"–2000" situation on one side
			   catexists(prevseason2) or
			   catexists(nextseason2)
			then
				hgap_success = true
				break
			end
		end
		hgap = hgap + 1
	end
	if hgap_success == false then
		hgap = tonumber(testgap) or hgap_default --tracked via defaultgapcat()
	end
	
	--preliminary scan to determine ir/regular spacing of nearby cats;
	--to limit expensive function calls, MOS:DATERANGE-violating cats are ignored;
	--an irregular-term-length series should follow "YYYY..hyph..YYYY" throughout
	local jlimit = 4  --4-a-side if all YYYY-YY, 3-a-side if all YYYY-YYYY, with some threshold in between
	if hgap <= hgap_limit_reg then --also to isolate temp vars
		--find # of nav-visible ir/regular-term-length cats
		local bwanchor = nstart       --backward anchor/common year
		local fwanchor = bwanchor + t --forward anchor/common year
		if regularparent == 'isolated' then
			fwanchor = bwanchor
		end
		local spangreen = '[<span style="color:green">j, g, k = ' --used for/when debugging via list-all-links=yes
		local spanblue = '<span style="color:blue">'
		local spanred = ' (<span style="color:red">'
		local span = '</span>'
		local lastg = nil --to check for run-on searches
		local lastk = nil --to check for run-on searches
		local endfound = false --switch used to stop searching forward
		local iirregs = 0 --index of tirregs[] for j < 0, since search starts from parent
		local j = -jlimit --index of tirregs[] for j > 0 & pseudo navh position
		while j <= jlimit do
			
			if j < 0 then --search backward from parent
				local gbreak = false --switch used to break out of g-loop
				local g = 0 --gap size
				while g <= hgap_limit_irreg do
					local k = 0 --term length: 0 = "0-length", 1+ = normal
					while k <= term_limit do
						local from = bwanchor - k - g
						local to   = bwanchor - g
						local full = mw.text.trim( firstpart..lspace..from..hyph..to..tspace..lastpart )
						if k == 0 then
							if regularparent ~= 'isolated' then --+restrict to g == 0 if repeating year problems arise
								to = '0-length'
								full = mw.text.trim( firstpart..lspace..from..tspace..lastpart )
								if catlinkfollowr( frame, full ).rtarget ~= nil then --#R followed
									table.insert( tlistallbwd, spangreen..j..', '..g..', '..k..span..'] '..full..spanred..'#R ignored'..span..')' )
									full, to = '', '' --don't use/follow 0-length cat #Rs from nav_hyphen(); otherwise gets messy
								end
							end
						end
						if (k >= 1) or		  --the normal case; only continue k = 0 if 0-length found
						   (to == '0-length') --ghetto "continue" (thx Lua) to avoid expensive searches for "UK MPs 1974-1974", etc.
						then
							table.insert( tlistallbwd, spangreen..j..', '..g..', '..k..span..'] '..full )
							if (k == 1) and
--							   (g == 0 or g == 1) and --commented to match j>0 case ("1995–96 in Federal Republic of Yugoslavia basketball")
							   (catexists(full) == false)
							then --allow bare-bones MOS:DATERANGE alternation, in case we're on a 0|1-gap, 1-year term series
								local to2 = string.match(to, '%d%d$')
								if to2 and to2 ~= '00' then --and not at a century transition (i.e. 1999–2000)
									to = to2
									full = mw.text.trim( firstpart..lspace..from..hyph..to..tspace..lastpart )
									table.insert( tlistallbwd, spangreen..j..', '..g..', '..k..span..'] '..full )
								end
							end
							if catexists(full) then
								if to == '0-length' then
									trackcat(13, 'Category series navigation range irregular, 0-length')
								end
								tlistallbwd[#tlistallbwd] = spanblue..tlistallbwd[#tlistallbwd]..span..' (found)'
								ttlens[ find_duration(full) ] = 1
								if j == -1 then tgapsj4[g] = 1 -- -1 since bwd search starts from parent @ -4 and ends at -1
								else tgaps[g] = 1 end
								iirregs = iirregs + 1
								tirregs['from-'..iirregs] = from
								tirregs['to-'..iirregs] = to
								bwanchor = from --ratchet down
								if to ~= '0-length' then
									gbreak = true
									break
								else
									g = 0 --soft-reset g, to keep stepping thru k
									j = j + 1 --save, but keep searching thru k
									if j > 0 then --(restore "> 3" if acts up) lest we keep searching bwd & finding 0-length cats ("MEPs for the Republic of Ireland 1973" & down)
										j = -1 --allow a normal, full search fwd after break
										gbreak = true
										break
									end
								end
							elseif (j >= 0) and
								   (lastg and lastk) and
								   ((lastg >= hgap_limit_irreg) or
									(lastk >= term_limit))
							then --bwd search exhausted and/or done (runaway bwd search on "2018–19 FIA World Endurance Championship season")
								j = -1 --allow a normal, full search fwd after break
								gbreak = true
								break
							end
						end --ghetto "continue"
						k = k + 1
						lastk = k
					end --while k <= term_limit do
					if gbreak == true then break end
					g = g + 1
					lastg = g
				end --while g <= hgap_limit_irreg do
			end --if j < 0
			
			if j > 0 and endfound == false then --search forward from parent
				local gbreak = false --switch used to break out of g-loop
				local g = 0 --gap size
				while g <= hgap_limit_irreg do
					local k = -2 --term length: -2 = "0-length", -1 = "2020–present", 0 = "2020–", 1+ = normal
					while k <= term_limit do
						local from = fwanchor + g
						local to4  = fwanchor + k + g	--override carefully
						local to2  = nil				--last 2 digits of to4, IIF exists
						if k == -1 then to4 = 'present'	--see if end-cat exists (present)
						elseif k == 0 then to4 = '' end	--see if end-cat exists (blank)
						local full = mw.text.trim( firstpart..lspace..from..hyph..to4..tspace..lastpart )
						if k == -2 then
							if regularparent ~= 'isolated' then --+restrict to g == 0 if repeating year problems arise
								to4 = '0-length' --see if 0-length cat exists
								full = mw.text.trim( firstpart..lspace..from..tspace..lastpart )
								if catlinkfollowr( frame, full ).rtarget ~= nil then --#R followed
									table.insert( tlistallfwd, spangreen..j..', '..g..', '..k..span..'] '..full..spanred..'#R ignored'..span..')' )
									full, to4 = '', '' --don't use/follow 0-length cat #Rs from nav_hyphen(); otherwise gets messy
								end
							end
						end
						if (k >= -1) or		   --only continue k = -2 if 0-length found
						   (to4 == '0-length') --ghetto "continue" (thx Lua) to avoid expensive searches for "UK MPs 1974-1974", etc.
						then
							table.insert( tlistallfwd, spangreen..j..', '..g..', '..k..span..'] '..full )
							if (k == 1) and
--							   (g == 0 or g == 1) and --commented to let "2002–03 in Scottish women's football" find "2008–09 in Scottish women's football"
							   (catexists(full) == false)
							then --allow bare-bones MOS:DATERANGE alternation, in case we're on a 0|1-gap, 1-year term series
								to2 = string.match(to4, '%d%d$')
								if to2 and to2 ~= '00' then --and not at a century transition (i.e. 1999–2000)
									full = mw.text.trim( firstpart..lspace..from..hyph..to2..tspace..lastpart )
									table.insert( tlistallfwd, spangreen..j..', '..g..', '..k..span..'] '..full )
								end
							end
							if catexists(full) then
								if to4 == '0-length' then
									if rtarget(frame, full) == full then --only use 0-length cats that don't #R
										trackcat(13, 'Category series navigation range irregular, 0-length')
									end
								end
								tirregs['from'..j] = from
								tirregs['to'..j] = (to2 or to4)
								if (k == -1) or (k == 0) then
									endfound = true --tentative
								else --k == { -2, > 0 }
									tlistallfwd[#tlistallfwd] = spanblue..tlistallfwd[#tlistallfwd]..span..' (found)'
									ttlens[ find_duration(full) ] = 1
									if j == 4 then tgapsj4[g] = 1
									else tgaps[g] = 1 end
									endfound = false
									if to4 ~= '0-length' then --k > 0
										fwanchor = to4 --ratchet up
										gbreak = true
										break --only break on k > 0 b/c old end-cat #Rs still exist like "Members of the Scottish Parliament 2011–"
									else --k == -2
										j = j + 1 --save, but keep searching k's, in case "1974" → "1974-1979"
										if j > jlimit then --lest we keep searching & finding 0-length cats ("2018 CONCACAF Champions League" & up)
											gbreak = true
											break
										elseif g == hgap_limit_irreg then
											--keep searching, since not a runaway, just far away ("American soccer clubs 1958–59 season")
											hgap_limit_irreg = hgap_limit_irreg + 1
										end
									end
								end
							end
						end --ghetto "continue"
						k = k + 1
						lastk = k
					end --while k <= term_limit do
					if gbreak == true then break end
					g = g + 1
					lastg = g
				end --while g <= hgap_limit_irreg do
			end --if j > 0 and endfound == false then
			
			if (lastg and lastk) and
			   (lastg > hgap_limit_irreg) and
			   (lastk > term_limit)
			then --search exhausted
				if j < 0 then j = 0 --bwd search exhausted; continue fwd
				elseif j > 0 then break end --fwd search exhausted
			end
			
			j = j + 1
		end --while j <= jlimit
	end --if hgap <= hgap_limit_reg
	
	--determine # of displayed navh elements based on "YYYY-YY" vs. "YYYY-YYYY" counts
	local Ythreshold = 3.3 --((YYYY-YY x 7) + (YYYY-YYYY x 2))/18 = 3.222; ((YYYY-YY x 6) + (YYYY-YYYY x 3))/18 = 3.333
	local Ycount = 0 --"Y" count
	local ycount = 0 --tirregs counter; # of contiguous #s
	for k, v in pairs (tirregs) do
		local dummy, dunce = mw.ustring.gsub(tostring(v), '%d', '') --why can't gsub just return a table??
		Ycount = Ycount + dunce
		ycount = ycount + 1
	end
	local ycount_limit = ((jlimit * 2) + 1) * 2 --i.e. ((4 * 2) + 1) * 2 = 18
	if ycount < ycount_limit then --fill in the blanks with Ycount_parent, since hidden/dne cats aren't in tirregs
		local dummy_finish = finish
		if not regularparent then dummy_finish = start end
		local dummy, dunce_from = mw.ustring.gsub(start,  '%d', '')
		local dummy, dunce_to   = mw.ustring.gsub(dummy_finish, '%d', '')
		local Ycount_parent_avg = (dunce_from + dunce_to)/2 --"YYYY-YYYY" = 4; "YYYY-YY" = 3
		Ycount = Ycount + (Ycount_parent_avg * (ycount_limit - ycount))
		ycount = ycount_limit
	end
	local iwidth = 3 --default to 3-a-side, 7 total
	local Y_per_y = Ycount / ycount --normalized range: [3-4]
	if Y_per_y < Ythreshold then
		iwidth = 4 --extend to 4-a-side, 9 total
	end
	
	--begin navhyphen
	local navh = '<div class="toccolours categorySeriesNavigation-range">\n'
	
	local navlist = {}
	local terminalcat = false --switch used to hide future cats
	local terminaltxt = nil
	local i = -iwidth --nav position
	while i <= iwidth do
		local from = nstart + i*(t+hgap) --the logical, but not necessarily correct, 'from'
		if tirregs['from'..i] then --prefer the irregular term table
			from = tonumber(tirregs['from'..i])
		else --fallback to lazy/naive 'from'
			if i > 0 and
			   tirregs['from'..(i-1)] and
			   tirregs['from'..(i-1)] >= from
			then --end of the line: avoid dups/past, and create reasonable grey'd ranges
				local greyto   = tonumber(tirregs['to' .. (i-1)]) or -9999
				local greyfrom = tonumber(tirregs['from'..(i-1)]) or -9999
				local grey = greyto --prefer 'to'
				if greyfrom > greyto then grey = greyfrom end --'from' fallback, in case "1995–96", "1995-present", etc.
				if grey > -9999 then
					if grey ~= greyto then
						from = grey + t + hgap --account for missing/incomplete 'to'
					else
						from = grey + hgap
					end
					tirregs['from'..i] = from --remember
					tirregs['to' .. i] = from + t
				end
			elseif i < 0 then
				local greyfrom
				local ii = 0
				while ii < 3 do
					ii = ii + 1
					greyfrom = tonumber(tirregs['from'..(i+ii)])
					if greyfrom then break end
				end
				from = (greyfrom or nstart) - ii*(t+hgap)
				tirregs['from'..i] = from --remember
				tirregs['to' .. i] = from + t
			end
		end
		local from2 = string.match(from, '%d?%d$')
		
		local to = tostring(from+t)	--the logical, naive range, but
		if tirregs['to'..i] then	--prefer irregular term table
			to = tirregs['to'..i]
		elseif regularparent == false and tirregs and i > 0 then
			to = tirregs['to-1']	--special treatment for parent terminal cats, since they have no natural 'to'
		end
		local to2 = string.match(to, '%d?%d$')
		local tofinal = (to2 or '')    --assume t=1 and abbreviated 'to' (the most common case)
		if t > 1 or                    --per MOS:DATERANGE (e.g. 1999-2004)
		  (from2 - (to2 or from2)) > 0 --century transition exception (e.g. 1999–2000)
		then
			tofinal = (to or '')       --default to the MOS-correct format, in case no fallbacks found
		end
		if to == '0-length' then
			tofinal = to
		end
		
		--check existance of 4-digit, MOS-correct range, with abbreviation fallback
		if tofinal ~= '0-length' then
			if t > 1 and string.len(from) == 4 then --e.g. 1999-2004
				--determine which link exists (full or abbr)
				local full = firstpart..lspace..from..hyph..tofinal..tspace..lastpart
				if not catexists(full) then
					local abbr = firstpart..lspace..from..hyph..to2..tspace..lastpart
					if catexists(abbr) then
						tofinal = (to2 or '') --rv to MOS-incorrect format; if full AND abbr DNE, then tofinal is still in its MOS-correct format
					end
				end
			elseif t == 1 then --full-year consecutive ranges are also allowed
				local abbr = firstpart..lspace..from..hyph..tofinal..tspace..lastpart --assume tofinal is in abbr format
				if not catexists(abbr) and tofinal ~= to then
					local full = firstpart..lspace..from..hyph..to..tspace..lastpart
					if catexists(full) then
						tofinal = (to or '') --if abbr AND full DNE, then tofinal is still in its abbr format (unless it's a century transition)
		end	end	end	end
		
		--populate navh
		if i ~= 0 then --left/right navh
			local orig = firstpart..lspace..from..hyph..tofinal..tspace..lastpart
			local disp = from..hyph..tofinal
			if tofinal == '0-length' then
				orig = firstpart..lspace..from..tspace..lastpart
				disp = from
			end
			local catlink = catlinkfollowr(frame, orig, disp, true) --force terminal cat display
			
			if terminalcat == false then
				terminaltxt = find_terminaltxt( disp ) --also sets tracking cats
				terminalcat = (terminaltxt ~= nil)
			end
			if catlink.rtarget and avoidself then --a {{Category redirect}} was followed, figure out why
				--determine new term length & gap size
				ttlens[ find_duration( catlink.rtarget ) ] = 1
				if i > -iwidth then
					local lastto = tirregs['to'..(i-1)]
					if lastto == nil then
						local lastfrom = nstart + (i-1)*(t+hgap)
						lastto = lastfrom+t --use last logical 'from' to calc lastto
					end
					if lastto then
						local gapcat = lastto..'-'..from --dummy cat to calc with
						local gap = find_duration(gapcat) or -1	--in case of nil,
						if iwidth == 4 then
							tgapsj4[ gap ] = 1 --tgapsj4[-1] are ignored later
						else
							tgaps[ gap ] = 1 --tgaps[-1] are ignored later
						end
					end
				end
				
				--display/tracking handling
				local base_regex = '%d+[–-]%d+'
				local origbase = mw.ustring.gsub(orig, base_regex, '')
				local rtarbase, rtarbase_success = mw.ustring.gsub(catlink.rtarget, base_regex, '')
				if rtarbase_success == 0 then
					local base_regex_lax = '%d%d%d%d' --in case rtarget is a year cat
					rtarbase, rtarbase_success = mw.ustring.gsub(catlink.rtarget, base_regex_lax, '')
				end
				local terminal_regex = '%d+[–-]'..(terminaltxt or '')..'$' --more manual ORs bc Lua regex sux
				if mw.ustring.match(orig, terminal_regex) then
					origbase = mw.ustring.gsub(orig, terminal_regex, '')
				end
				if mw.ustring.match(catlink.rtarget, terminal_regex) then
					--finagle/overload terminalcat type to set nmaxseas on 1st occurence only
					if terminalcat == false then terminalcat = 1 end
					local dummy = find_terminaltxt( catlink.rtarget ) --also sets tracking cats
					rtarbase = mw.ustring.gsub(catlink.rtarget, terminal_regex, '')
				end
				origbase = mw.text.trim(origbase)
				rtarbase = mw.text.trim(rtarbase)
				if origbase ~= rtarbase then
					trackcat(6, 'Category series navigation range redirected (base change)')
				elseif terminalcat == 1 then
					trackcat(8, 'Category series navigation range redirected (end)')
				else --origbase == rtarbase
					local all4s_regex = '%d%d%d%d[–-]%d%d%d%d'
					local orig_all4s = mw.ustring.match(orig, all4s_regex)
					local rtar_all4s = mw.ustring.match(catlink.rtarget, all4s_regex)
					if orig_all4s and rtar_all4s then
						trackcat(10, 'Category series navigation range redirected (other)')
					else
						local year_regex1 = '%d%d%d%d$'
						local year_regex2 = '%d%d%d%d[%s%)]'
						local year_rtar = mw.ustring.match(catlink.rtarget, year_regex1) or
										  mw.ustring.match(catlink.rtarget, year_regex2)
						if orig_all4s and year_rtar then
							trackcat(7, 'Category series navigation range redirected (var change)')
						else
							trackcat(9, 'Category series navigation range redirected (MOS)')
						end
					end
				end
			end
			
			if terminalcat then --true or 1
				if type(terminalcat) ~= 'boolean' then nmaxseas = from end --only want to do this once
				terminalcat = true --done finagling/overloading
			end
			if (from >= 0) and (nminseas <= from) and (from <= nmaxseas) then
				table.insert(navlist, catlink.navelement)
				if terminalcat then nmaxseas = nminseas_default end --prevent display of future ranges
			else
				local hidden = '<span style="visibility:hidden">'..disp..'</span>'
				table.insert(navlist, hidden)
				if listall then
					tlistall[#tlistall] = tlistall[#tlistall]..' ('..hidden..')'
				end
			end
		else --center navh
			if finish == -1 then finish = 'present'
			elseif finish == 0 then finish = '<span style="visibility:hidden">'..start..'</span>' end
			local disp = start..hyph..finish
			if regularparent == 'isolated' then disp = start end
			table.insert(navlist, '<b>'..disp..'</b>')
		end
		
		i = i + 1
	end
	
	-- add the list
	navh = navh..horizontal(navlist)..'\n'
	
	--tracking cats & finalize
	if avoidself then
		local igaps  = 0 --# of diff gap sizes > 0 found
		local itlens = 0 --# of diff term lengths found
		for s = 1, hgap_limit_reg do --must loop; #tgaps, #ttlens unreliable
			igaps = igaps + (tgaps[s] or 0)
		end
		if iwidth == 4 then --only count gaps if they were displayed ("Karnataka MLAs 1957–1962")
			for s = 1, hgap_limit_reg do
				igaps = igaps + (tgapsj4[s] or 0)
			end
		end
		for s = 0, term_limit do
			itlens = itlens + (ttlens[s] or 0)
		end
		if igaps  > 0 then trackcat(11, 'Category series navigation range gaps') end
		if itlens > 1 and ttrackingcats[13] == '' then --avoid duplication in "Category series navigation range irregular, 0-length"
			trackcat(12, 'Category series navigation range irregular')
		end
	end
	isolatedcat()
	defaultgapcat(not hgap_success)
	if listall then
		return listalllinks()
	else
		return navh..'</div>'
	end
end


--[[=========================={{  nav_tvseason  }}============================]]

local function nav_tvseason( frame, firstpart, tv, lastpart, maximumtv )
	--Expects a PAGENAME of the form "Futurama season 1 episodes", where
	--	firstpart = Futurama season
	--	tv        = 1
	--	lastpart  = episodes
	--	maximumtv = 7 ('max' tv season parameter; optional; defaults to 9999)
	tv = tonumber(tv)
	if tv == nil then
		errors = p.errorclass('Function nav_tvseason can\'t recognize the TV season number sent to its 3rd parameter.')
		return p.failedcat(errors, 'T')
	end
	
	--"(season 1) episodes" -> "season 1 episodes" following March 2024 RfC:
	--[[Wikipedia talk:Naming conventions (television)#Follow-up RfC on TV season article titles]]
	--                  [[Special:Permalink/1216885280#Follow-up RfC on TV season article titles]]
	local tspace = ' ' --"season 1 episodes"
	local parenth_check = string.match(lastpart, '^%)')
	if parenth_check then tspace = '' end --accommodate old style "(season 1) episodes" just in case
	
	local maxtv_default = 9999
	local maxtv = tonumber(maximumtv) or maxtv_default --allow +/- qualifier
	if maxtv < tv then maxtv = tv end --input error; maxtv should be >= parent
	
	--begin navtvseason
	local navt = '<div class="toccolours categorySeriesNavigation-range">\n'
	
	local navlist = {}
	local prepad = ''
	local i = -5 --nav position
	while i <= 5 do
		local t = tv + i
		if i ~= 0 then --left/right navt
			local catlink = catlinkfollowr( frame, firstpart..' '..t..tspace..lastpart, t )
			if t >= 1 and t <= maxtv then --hardcode mintv
				if catlink.rtarget then --a {{Category redirect}} was followed
					trackcat(25, 'Category series navigation TV season redirected')
				end
				if catlink.catexists or
				   (maxtv ~= maxtv_default and t <= maxtv)
				then
					table.insert(navlist, prepad..catlink.navelement) --display normally
					prepad = ''
				else
					local postpad = '<span style="visibility:hidden"> • '..t..'</span>'
					navlist[#navlist] = navlist[#navlist]..postpad
					if listall then tlistall[#tlistall] = tlistall[#tlistall]..' ('..postpad..')' end
				end	
			elseif t < 1 then
				prepad = prepad..'<span style="visibility:hidden"> • '..'0'..'</span>'
				if listall then tlistall[#tlistall] = (tlistall[#tlistall] or '')..' (x)' end
			else --t > maxtv
				local postpad = '<span style="visibility:hidden"> • '..t..'</span>'
				navlist[#navlist] = navlist[#navlist]..postpad
				if listall then tlistall[#tlistall] = tlistall[#tlistall]..' ('..postpad..')' end
			end
		else --center navt
			table.insert(navlist, prepad..'<b>'..tv..'</b>')
			prepad = ''
		end
		
		i = i + 1
	end
	-- add the list
	navt = navt..horizontal(navlist)..'\n'
	isolatedcat()
	if listall then
		return listalllinks()
	else
		return navt..'</div>'
	end
end


--[[==========================={{  nav_decade  }}=============================]]

local function nav_decade( frame, firstpart, decade, lastpart, mindecade, maxdecade )
	--Expects a PAGENAME of the form "Some sequential 2000 example cat", where
	--	firstpart = Some sequential
	--	decade    = 2000
	--	lastpart  = example cat
	--	mindecade = 1800 ('min' decade parameter; optional; defaults to -9999)
	--	maxdecade = 2020 ('max' decade parameter; optional; defaults to 9999)
	
	--sterilize dec
	local dec = sterilizedec(decade)
	if dec == nil then
		errors = p.errorclass('Function nav_decade was sent "'..(decade or '')..'" as its 2nd parameter, '..
							'but expects a 1 to 4-digit year ending in "0".')
		return p.failedcat(errors, 'D')
	end
	local ndec = tonumber(dec)
	
	--sterilize mindecade & determine AD/BC
	local mindefault = '-9999'
	local mindec = sterilizedec(mindecade) --returns a tostring(unsigned int), or nil
	if mindec then
		if string.match(mindecade, '-%d') or
		   string.match(mindecade, 'BC')
		then
			mindec = '-'..mindec --better +/-0 behavior with strings (0-initialized int == "-0" string...)
		end
	elseif mindec == nil and mindecade and mindecade ~= '' then
		errors = p.errorclass('Function nav_decade was sent "'..(mindecade or '')..'" as its 4th parameter, '..
							'but expects a 1 to 4-digit year ending in "0", the earliest decade to be shown.')
		return p.failedcat(errors, 'E')
	else --mindec == nil
		mindec = mindefault --tonumber() later, after error checks
	end
	
	--sterilize maxdecade & determine AD/BC
	local maxdefault = '9999'
	local maxdec = sterilizedec(maxdecade) --returns a tostring(unsigned int), or nil + error
	if maxdec then
		if string.match(maxdecade, '-%d') or
		   string.match(maxdecade, 'BC')
		then                     --better +/-0 behavior with strings (0-initialized int == "-0" string...),
			maxdec = '-'..maxdec --but a "-0" string -> tonumber() -> tostring() = "-0",
		end                      --and a  "0" string -> tonumber() -> tostring() =  "0"
	elseif maxdec == nil and maxdecade and maxdecade ~= '' then
		errors = p.errorclass('Function nav_decade was sent "'..(maxdecade or '')..'" as its 5th parameter, '..
							'but expects a 1 to 4-digit year ending in "0", the highest decade to be shown.')
		return p.failedcat(errors, 'F')
	else --maxdec == nil
		maxdec = maxdefault
	end
	
	local tspace = ' ' --assume trailing space for "1950s in X"-type cats
	if string.match(lastpart, '^-') then tspace = '' end --DNE for "1970s-related"-type cats
	
	--AD/BC switches & vars
	
	local parentBC = string.match(lastpart, '^BC') --following the "0s BC" convention for all years BC
	lastpart = mw.ustring.gsub(lastpart, '^BC%s*', '') --handle BC separately; AD never used
	--TODO?: handle BCE, but only if it exists in the wild
	
	local dec0to40AD = (ndec >= 0 and ndec <= 40 and not parentBC) --special behavior in this range
	local switchADBC = 1                 --  1=AD parent
	if parentBC then switchADBC = -1 end -- -1=BC parent; possibly adjusted later
	local BCdisp = ''
	local D = -math.huge --secondary switch & iterator for AD/BC transition
	
	--check non-default min/max more carefully
	if mindec ~= mindefault then
		if tonumber(mindec) > ndec*switchADBC then
			mindec = tostring(ndec*switchADBC) --input error; mindec should be <= parent
		end
	end
	if maxdec ~= maxdefault then
		if tonumber(maxdec) < ndec*switchADBC then
			maxdec = tostring(ndec*switchADBC) --input error; maxdec should be >= parent
		end
	end
	local nmindec = tonumber(mindec) --similar behavior to nav_year & nav_nordinal
	local nmaxdec = tonumber(maxdec) --similar behavior to nav_nordinal
	
	--begin navdecade
	local bnb = '' --border/no border
	if navborder == false then --for Category series navigation year and decade
		bnb = 'categorySeriesNavigation-range-transparent'
	end
	local navd = '<div class="toccolours categorySeriesNavigation-range '..bnb..'">\n'
	
	local navlist = {}
	local i = -50 --nav position x 10
	while i <= 50 do
		local d = ndec + i*switchADBC
		
		local BC = ''
		BCdisp = ''
		if dec0to40AD then
			if D < -10 then
				d = math.abs(d + 10) --b/c 2 "0s" decades exist: "0s BC" & "0s" (AD)
				BC = 'BC '
				if d == 0 then
					D = -10 --track 1st d = 0 use (BC)
				end
			elseif D >= -10 then
				D = D + 10 --now iterate from 0s AD
				d = D      --2nd d = 0 use
			end
		elseif parentBC then
			if switchADBC == -1 then --parentBC looking at the BC side (the common case)
				BC = 'BC '
				if d == 0 then     --prepare to switch to the AD side on the next iteration
					switchADBC = 1 --1st d = 0 use (BC)
					D = -10        --prep
				end
			elseif switchADBC == 1 then --switched to the AD side
				D = D + 10 --now iterate from 0s AD
				d = D      --2nd d = 0 use (on first use)
			end
		end
		if BC ~= '' and ndec <= 50 then
			BCdisp = ' BC' --show BC for all BC decades whenever a "0s" is displayed on the nav
		end
		
		--determine target cat
		local disp = d..'s'..BCdisp
		local catlink = catlinkfollowr( frame, firstpart..' '..d..'s'..tspace..BC..lastpart, disp )
		if catlink.rtarget then --a {{Category redirect}} was followed
			trackcat(18, 'Category series navigation decade redirected')
		end
		
		--populate left/right navd
		local shown = navcenter(i, catlink)
		local hidden = '<span style="visibility:hidden">'..disp..'</span>'
		local dsign = d --use d for display & dsign for logic
		if BC ~= '' then dsign = -dsign end
		if (nmindec <= dsign) and (dsign <= nmaxdec) then
			if dsign == 0 and (nmindec == 0 or nmaxdec == 0) then --distinguish b/w -0 (BC) & 0 (AD)
				--"zoom in" on +/- 0 and turn dsign/min/max temporarily into +/- 1 for easier processing
				local zsign, zmin, zmax = 1, nmindec, nmaxdec
				if BC ~= '' then zsign = -1 end
				if     mindec == '-0' then zmin = -1
				elseif mindec ==  '0' then zmin =  1 end
				if     maxdec == '-0' then zmax = -1
				elseif maxdec ==  '0' then zmax =  1 end
				
				if (zmin <= zsign) and (zsign <= zmax) then
					table.insert(navlist, shown)
					hidden = nil
				else
					table.insert(navlist, hidden)
				end
			else
				table.insert(navlist, shown)--the common case
				hidden = nil
			end
		else
			table.insert(navlist, hidden)
		end
		if listall and hidden then
			tlistall[#tlistall] = tlistall[#tlistall]..' ('..hidden..')'
		end
		
		i = i + 10
	end
	-- add the list
	navd = navd..horizontal(navlist)..'\n'
	isolatedcat()
	if listall then
		return listalllinks()
	else
		return navd..'</div>'
	end
end


--[[============================{{  nav_year  }}==============================]]

local function nav_year( frame, firstpart, year, lastpart, minimumyear, maximumyear )
	--Expects a PAGENAME of the form "Some sequential 1760 example cat", where
	--	firstpart   = Some sequential
	--	year        = 1760
	--	lastpart    = example cat
	--	minimumyear = 1758 ('min' year parameter; optional)
	--	maximumyear = 1800 ('max' year parameter; optional)
	local minyear_default = -9999
	local maxyear_default =  9999
	year = tonumber(year) or tonumber(mw.ustring.match(year or '', '^%s*(%d*)'))
	local minyear = tonumber(string.match(minimumyear or '', '-?%d+')) or minyear_default --allow +/- qualifier
	local maxyear = tonumber(string.match(maximumyear or '', '-?%d+')) or maxyear_default --allow +/- qualifier
	if string.match(minimumyear or '', 'BC') then minyear = -math.abs(minyear) end --allow BC qualifier (AD otherwise assumed)
	if string.match(maximumyear or '', 'BC') then maxyear = -math.abs(maxyear) end --allow BC qualifier (AD otherwise assumed)
	
	if year == nil then
		errors = p.errorclass('Function nav_year can\'t recognize the year sent to its 3rd parameter.')
		return p.failedcat(errors, 'Y')
	end
	
	--AD/BC switches & vars
	
	local yearBCElastparts = { --needed for parent = AD 1-5, when the BC/E format is unknown
		--"BCE" removed to match both AD & BCE cats; easier & faster than multiple string.match()s
		['example_Hebrew people_example'] = 'BCE', --example entry format; add to & adjust as needed
	}
	local parentAD = string.match(firstpart, 'AD$')  --following the "AD 1" convention from AD 1 to AD 10
	local parentBC = string.match(lastpart, '^BCE?') --following the "1 BC" convention for all years BC
	firstpart = mw.ustring.gsub(firstpart, '%s*AD$', '') --handle AD/BC separately for easier & faster accounting
	lastpart  = mw.ustring.gsub(lastpart,  '^BCE?%s*', '')
	local BCe = parentBC or yearBCElastparts[lastpart] or 'BC' --"BC" default
	
	local year1to10 = (year >= 1 and year <= 10)
	local year1to10ADBC = year1to10 and (parentBC or parentAD) --special behavior 1-10 for low-# non-year series
	local year1to15AD = (year >= 1 and year <= 15 and not parentBC) --special behavior 1-15 for AD/BC display
	local switchADBC = 1                 --  1=AD parent
	if parentBC then switchADBC = -1 end -- -1=BC parent; possibly adjusted later
	local Y = 0 --secondary iterator for AD-on-a-BC-parent
	
	if minyear > year*switchADBC then minyear = year*switchADBC end --input error; minyear should be <= parent
	if maxyear < year*switchADBC then maxyear = year*switchADBC end --input error; maxyear should be >= parent
	
	local lspace = ' ' --leading space before year, after firstpart
	if string.match(firstpart, '[%-VW]$') then
		lspace = '' --e.g. "Straight-8 engines"
	end
	
	local tspace = ' ' --trailing space after year, before lastpart
	if string.match(lastpart, '^-') then
		tspace = '' --e.g. "2018-related timelines"
	end
	
	--determine interyear gap size to condense special category types, if possible
	local ygapdefault = 1 --assume/start at the most common case: 2001, 2002, etc.
	local ygap = ygapdefault
	if string.match(lastpart, 'presidential') then
		local ygap1, ygap2 = ygapdefault, ygapdefault --need to determine previous & next year gaps indepedently
		local ygap1_success, ygap2_success = false, false
		
		local prevseason = nil
		while ygap1 <= ygap_limit do --Czech Republic, Poland, Sri Lanka, etc. have 5-year terms
			prevseason = firstpart..lspace..(year-ygap1)..tspace..lastpart
			if catexists(prevseason) then
				ygap1_success = true
				break
			end
			ygap1 = ygap1 + 1
		end
		
		local nextseason = nil
		while ygap2 <= ygap_limit do --Czech Republic, Poland, Sri Lanka, etc. have 5-year terms
			nextseason = firstpart..lspace..(year+ygap2)..tspace..lastpart
			if catexists(nextseason) then
				ygap2_success = true
				break
			end
			ygap2 = ygap2 + 1
		end
		
		if ygap1_success and ygap2_success then
			if ygap1 == ygap2 then ygap = ygap1 end
		elseif ygap1_success then  ygap = ygap1
		elseif ygap2_success then  ygap = ygap2
		end
	end
	
	--skip non-existing years, if requested
	local ynogaps = {} --populate with existing years in the range, at most, [year - (skipgaps_limit * 5), year + (skipgaps_limit * 5)]
	if skipgaps then
		if minyear == minyear_default then
			minyear = 0 --automatically set minyear to 0, as AD/BC not supported anyway
		end
		if (year > 70) or --add support for AD/BC (<= AD 10) if/when needed
		   (minyear >= 0 and --must be a non-year series like "AC with 0 elements"
		   	not parentAD and not parentBC)
		then
			local yskipped = {} --track skipped y's to avoid double-checking
			local cat, found, Yeary
			
			 --populate nav element queue outwards positively from the parent
			local Year = year --to save/ratchet progression
			local i = 1
			while i <= 5 do
				local y = 1
				while y <= skipgaps_limit do
					found = false
					Yeary = Year + y
					if yskipped[Yeary] == nil then
						yskipped[Yeary] = Yeary
						cat = firstpart..lspace..Yeary..tspace..lastpart
						found = catexists(cat)
						if found then break end
					end
					y = y + 1
				end
				if found then Year = Yeary
				else          Year = Year + 1 end
				ynogaps[i] =  Year
				i = i + 1
			end
			
			ynogaps[0] = year --the parent
			
			--populate nav element queue outwards negatively from the parent
			Year = year --reset ratchet
			i = -1
			while i >= -5 do
				local y = -1
				while y >= -skipgaps_limit do
					found = false
					Yeary = Year + y
					if yskipped[Yeary] == nil then
						yskipped[Yeary] = Yeary
						cat = firstpart..lspace..Yeary..tspace..lastpart
						found = catexists(cat)
						if found then break end
					end
					y = y - 1
				end
				if found then Year = Yeary
				else          Year = Year - 1 end
				ynogaps[i] =  Year
				i = i - 1
			end
		else
			skipgaps = false --TODO: AD/BC support, then lift BC restrictions @ [[Template:Establishment category BC]] & [[Template:Year category header/core]]
		end
	end
	
	--begin navyears
	local navy = '<div class="toccolours categorySeriesNavigation-range">\n'
	
	local navlist = {}
	local y
	local j = 0 --decrementor for special cases "2021 World Rugby Sevens Series" -> "2021–2022"
	local i = -5 --nav position
	while i <= 5 do
		if skipgaps then
			y = ynogaps[i]
		else
			y = year + i*ygap*switchADBC - j
		end
		local BCdisp = ''
		if i ~= 0 then --left/right navy
			
			local AD = ''
			local BC = ''
			if year1to15AD and not
			   (year1to10 and not year1to10ADBC) --don't AD/BC 1-10's if parents don't contain AD/BC
			then
				if year >= 11 then --parent = AD 11-15
					if y <= 10 then --prepend AD on y = 1-10 cats only, per existing cats
						AD = 'AD '
					end
					
				elseif year >= 1 then --parent = AD 1-10
					if y <= 0 then
						BC = BCe..' '
						y = math.abs(y - 1) --skip y = 0 (DNE)
					elseif y >= 1 and y <= 10 then --prepend AD on y = 1-10 cats only, per existing cats
						AD = 'AD '
					end
				end
				
			elseif parentBC then
				if switchADBC == -1 then --displayed y is in the BC regime
					if y >= 1 then     --the common case
						BC = BCe..' '
					elseif y == 0 then --switch from BC to AD regime
						switchADBC = 1
					end
				end
				if switchADBC == 1 then --displayed y is now in the AD regime
					Y = Y + 1 --skip y = 0 (DNE)
					y = Y     --easiest solution: start another iterator for these AD y's displayed on a BC year parent
					AD = 'AD '
				end
			end
			if BC ~= '' and year <= 5 then --only show 'BC' for parent years <= 5: saves room, easier to read,
				BCdisp = ' '..BCe          --and 6 is the first/last nav year that doesn't need a disambiguator;
			end                            --the center/parent year will always show BC, so no need to show it another 10x
			
			--populate left/right navy
			local ysign = y --use y for display & ysign for logic
			local disp = y..BCdisp
			if BC ~= '' then ysign = -ysign end
			local firsttry = firstpart..lspace..AD..y..tspace..BC..lastpart
			if (minyear <= ysign) and (ysign <= maxyear) then
				local catlinkAD = catlinkfollowr( frame, firsttry, disp ) --try AD
				local catlink = catlinkAD --tentative winner
				if AD ~= '' then --for "ACArt with 5 suppressed elements"-type cats
					local catlinkNoAD = catlinkfollowr( frame, firstpart..lspace..y..tspace..BC..lastpart, disp ) --try !AD
					if catlinkNoAD.catexists == true then
						catlink = catlinkNoAD --usurp
					elseif listall then
						tlistall[#tlistall] = tlistall[#tlistall]..' (tried; not displayed)<sup>1</sup>'
					end
				end
				if (AD..BC == '') and (catlink.catexists == false) and (y >= 1000) then --!ADBC & DNE; 4-digit only, to be frugal
					--try basic hyphenated cats: 1-year, endash, MOS-correct only, no #Rs
					local yHyph_4 = y..'–'..(y+1) --try 2010–2011 type cats
					local catlinkHyph_4 = catlinkfollowr( frame, firstpart..lspace..yHyph_4..tspace..BC..lastpart, yHyph_4 )
					if catlinkHyph_4.catexists and catlinkHyph_4.rtarget == nil then --exists & no #Rs
						catlink = catlinkHyph_4 --usurp
						trackcat(27, 'Category series navigation year and range')
					else
						if listall then
							tlistall[#tlistall] = tlistall[#tlistall]..' (tried; not displayed)<sup>2</sup>'
						end
						local yHyph_2 = y..'–'..string.match(y+1, '%d%d$') --try 2010–11 type cats
						if i == 1 then
							local yHyph_2_special = (y-1)..'–'..string.match(y, '%d%d$') --try special case 2021 -> 2021–22
							local catlinkHyph_2_special = catlinkfollowr( frame, firstpart..lspace..yHyph_2_special..tspace..BC..lastpart, yHyph_2_special )
							if catlinkHyph_2_special.catexists and catlinkHyph_2_special.rtarget == nil then --exists & no #Rs
								catlink = catlinkHyph_2_special --usurp
								trackcat(27, 'Category series navigation year and range')
								j = 1
							elseif listall then
								tlistall[#tlistall] = tlistall[#tlistall]..' (tried; not displayed)<sup>3</sup>'
							end
						end
						if not (i == 1 and j == 1) then
							local catlinkHyph_2 = catlinkfollowr( frame, firstpart..lspace..yHyph_2..tspace..BC..lastpart, yHyph_2 )
							if catlinkHyph_2.catexists and catlinkHyph_2.rtarget == nil then --exists & no #Rs
								catlink = catlinkHyph_2 --usurp
								trackcat(27, 'Category series navigation year and range')
							elseif listall then
								tlistall[#tlistall] = tlistall[#tlistall]..' (tried; not displayed)<sup>4</sup>'
							end
						end
					end
				end
				if catlink.rtarget then --#R followed; determine why
					local r = catlink.rtarget
					local c = catlink.cat
					local year_regex  = '%d%d%d%d[–-]?%d?%d?%d?%d?' --prioritize year/range stripping, e.g. for "2006 Super 14 season"
					local hyph_regex  = '%d%d%d%d[–-]%d+' --stricter
					local num_regex   = '%d+' --strip any number otherwise
					local final_regex = nil   --best choice goes here
					if mw.ustring.match(r, year_regex) and mw.ustring.match(c, year_regex) then
						final_regex = year_regex
					elseif mw.ustring.match(r, num_regex) and mw.ustring.match(c, num_regex) then
						final_regex = num_regex
					end
					if final_regex then
						local r_base = mw.ustring.gsub(r, final_regex, '')
						local c_base = mw.ustring.gsub(c, final_regex, '')
						if r_base ~= c_base then
							trackcat(19, 'Category series navigation year redirected (base change)') --acceptable #R target
						elseif mw.ustring.match(r, hyph_regex) then
							trackcat(20, 'Category series navigation year redirected (var change)') --e.g. "2008 in Scottish women's football" to "2008–09"
						else
							trackcat(21, 'Category series navigation year redirected (other)') --exceptions go here
						end
					else
						trackcat(20, 'Category series navigation year redirected (var change)') --e.g. "V2 engines" to "V-twin engines"
					end
				end
				table.insert(navlist, catlink.navelement)
			else --OOB vs min/max
				local hidden = '<span style="visibility:hidden">'..disp..'</span>'
				table.insert(navlist, hidden)
				if listall then
					local dummy = catlinkfollowr( frame, firsttry, disp )
					tlistall[#tlistall] = tlistall[#tlistall]..' ('..hidden..')'
				end
			end
		else --center navy
			if parentBC then BCdisp = ' '..BCe end
			table.insert(navlist, '<b>'..year..BCdisp..'</b>')
		end
		
		i = i + 1
	end
	
	--add the list
	navy = navy..horizontal(navlist)..'\n'
	
	isolatedcat()
	if listall then
		return listalllinks()
	else
		return navy..'</div>'
	end
end


--[[==========================={{  nav_roman  }}==============================]]

local function nav_roman( frame, firstpart, roman, lastpart, minimumrom, maximumrom )
	local toarabic = require('Module:ConvertNumeric').roman_to_numeral
	local toroman  = require('Module:Roman').main
	
	--sterilize/convert rom/num
	local num = tonumber(toarabic(roman))
	local rom = toroman({ [1] = num })
	if num == nil or rom == nil then --out of range or some other error
		errors = p.errorclass('Function nav_roman can\'t recognize one or more of "'..(num or 'nil')..'" & "'..
							(rom or 'nil')..'" in category "'..firstpart..' '..roman..' '..lastpart..'".')
		return p.failedcat(errors, 'R')
	end
	
	--sterilize min/max
	local minrom = tonumber(minimumrom or '') or tonumber(toarabic(minimumrom or ''))
	local maxrom = tonumber(maximumrom or '') or tonumber(toarabic(maximumrom or ''))
	if minrom < 1 then minrom = 1 end    --toarabic() returns -1 on error
	if maxrom < 1 then maxrom = 9999 end --toarabic() returns -1 on error
	if minrom > num then minrom = num end
	if maxrom < num then maxrom = num end
	
	--begin navroman
	local navr = '<div class="toccolours categorySeriesNavigation-range">\n'
	
	local navlist = {}
	local i = -5 --nav position
	while i <= 5 do
		local n = num + i
		
		if n >= 1 then
			local r = toroman({ [1] = n })
			if i ~= 0 then --left/right navr
				local catlink = catlinkfollowr( frame, firstpart..' '..r..' '..lastpart, r )
				if minrom <= n and n <= maxrom then
					if catlink.rtarget then --a {{Category redirect}} was followed
						trackcat(22, 'Category series navigation roman numeral redirected')
					end
					table.insert(navlist, catlink.navelement)
				else
					local hidden = '<span style="visibility:hidden">'..r..'</span>'
					table.insert(navlist, hidden)
					if listall then
						tlistall[#tlistall] = tlistall[#tlistall]..' ('..hidden..')'
					end
				end
			else --center navr
				table.insert(navlist, '<b>'..r..'</b>')
			end
		else
			table.insert(navlist, '<span style="visibility:hidden">I</span>')
		end
		
		i = i + 1
	end
	
	-- add the list
	navr = navr..horizontal(navlist)..'\n'
	isolatedcat()
	if listall then
		return listalllinks()
	else
		return navr..'</div>'
	end
end


--[[=========================={{  nav_nordinal  }}============================]]

local function nav_nordinal( frame, firstpart, ord, lastpart, minimumord, maximumord )
	local nord = tonumber(ord)
	local minord = tonumber(string.match(minimumord or '', '(-?%d+)[snrt]?[tdh]?')) or -9999 --allow full ord & +/- qualifier
	local maxord = tonumber(string.match(maximumord or '', '(-?%d+)[snrt]?[tdh]?')) or  9999 --allow full ord & +/- qualifier
	if string.match(minimumord or '', 'BC') then minord = -math.abs(minord) end --allow BC qualifier (AD otherwise assumed)
	if string.match(maximumord or '', 'BC') then maxord = -math.abs(maxord) end --allow BC qualifier (AD otherwise assumed)
	
	local temporal = string.match(lastpart, 'century') or
					 string.match(lastpart, 'millennium')
	
	local tspace = ' ' --assume a trailing space after ordinal
	if string.match(lastpart, '^-') then tspace = '' end --DNE for "19th-century"-type cats
	
	--AD/BC switches & vars
	
	local ordBCElastparts = { --needed for parent = AD 1-5, when the BC/E format is unknown
		--lists the lastpart of valid BCE cats
		--"BCE" removed to match both AD & BCE cats; easier & faster than multiple string.match()s
		['-century Hebrew people'] = 'BCE', --WP:CFD/Log/2016 June 21#Category:11th-century BC Hebrew people
		['-century Jews']          = 'BCE', --co-nominated
		['-century Judaism']       = 'BCE', --co-nominated
		['-century rabbis']        = 'BCE', --co-nominated
		['-century High Priests of Israel'] = 'BCE',
	}
	local parentBC = mw.ustring.match(lastpart, '%s(BCE?)')       --"1st-century BC" format
	local lastpartNoBC = mw.ustring.gsub(lastpart, '%sBCE?', '')  --easier than splitting lastpart up in 2; AD never used
	local BCe = parentBC or ordBCElastparts[lastpartNoBC] or 'BC' --"BC" default
	
	local switchADBC = 1                 --  1=AD parent
	if parentBC then switchADBC = -1 end -- -1=BC parent; possibly adjusted later
	local O = 0 --secondary iterator for AD-on-a-BC-parent
	
	if not temporal and minord < 1 then minord = 1 end --nothing before "1st parliament", etc.
	if minord > nord*switchADBC then minord = nord*switchADBC end --input error; minord should be <= parent
	if maxord < nord*switchADBC then maxord = nord*switchADBC end --input error; maxord should be >= parent
	
	--begin navnordinal
	local bnb = '' --border/no border
	if navborder == false then --for Category series navigation decade and century
		bnb = 'categorySeriesNavigation-range-transparent'
	end
	local navo = '<div class="toccolours categorySeriesNavigation-range '..bnb..'">\n'
	
	local navlist = {}
	local i = -5 --nav position
	while i <= 5 do
		local o = nord + i*switchADBC
		local BC = ''
		local BCdisp = ''
		if parentBC then
			if switchADBC == -1 then --parentBC looking at the BC side
				if o >= 1 then     --the common case
					BC = ' '..BCe
				elseif o == 0 then --switch to the AD side
					BC = ''
					switchADBC = 1
				end
			end
			if switchADBC == 1 then --displayed o is now in the AD regime
				O = O + 1 --skip o = 0 (DNE)
				o = O     --easiest solution: start another iterator for these AD o's displayed on a BC year parent
			end
		elseif o <= 0 then --parentAD looking at BC side
			BC = ' '..BCe
			o = math.abs(o - 1) --skip o = 0 (DNE)
		end
		if BC ~= '' and nord <= 5 then --only show 'BC' for parent ords <= 5: saves room, easier to read,
			BCdisp = ' '..BCe          --and 6 is the first/last nav ord that doesn't need a disambiguator;
		end                            --the center/parent ord will always show BC, so no need to show it another 10x
		
		--populate left/right navo
		local oth = p.addord(o)
		local osign = o --use o for display & osign for logic
		if BC ~= '' then osign = -osign end
		local hidden = '<span style="visibility:hidden">'..oth..'</span>'
		if temporal then --e.g. "3rd-century BC"
			local lastpart = lastpartNoBC --lest we recursively add multiple "BC"s
			if BC ~= '' then
				lastpart = string.gsub(lastpart, temporal, temporal..BC) --replace BC if needed
			end
			local catlink = catlinkfollowr( frame, firstpart..' '..oth..tspace..lastpart, oth..BCdisp )
			if (minord <= osign) and (osign <= maxord) then
				if catlink.rtarget then --a {{Category redirect}} was followed
					trackcat(23, 'Category series navigation nordinal redirected')
				end
				table.insert(navlist, navcenter(i, catlink))
			else
				table.insert(navlist, hidden)
				if listall then
					tlistall[#tlistall] = tlistall[#tlistall]..' ('..hidden..')'
				end
			end
		elseif BC == '' and minord <= osign and osign <= maxord then --e.g. >= "1st parliament"
			local catlink = catlinkfollowr( frame, firstpart..' '..oth..tspace..lastpart, oth )
			if catlink.rtarget then --a {{Category redirect}} was followed
				trackcat(23, 'Category series navigation nordinal redirected')
			end
			table.insert(navlist, navcenter(i, catlink))
		else --either out-of-range (hide), or non-temporal + BC = something might be wrong (2nd X parliament BC?); handle exceptions if/as they arise
			table.insert(navlist, hidden)
		end
		
		i = i + 1
	end
	
	navo = navo..horizontal(navlist)..'\n'
	
	isolatedcat()
	if listall then
		return listalllinks()
	else
		return navo..'</div>'
	end
end


--[[========================={{  nav_wordinal  }}=============================]]

local function nav_wordinal( frame, firstpart, word, lastpart, minimumword, maximumword, ordinal, frame )
	--Module:ConvertNumeric.spell_number2() args:
	--   ordinal == true : 'second' is output instead of 'two'
	--   ordinal == false: 'two' is output instead of 'second'
	local ord2eng = require('Module:ConvertNumeric').spell_number2
	local eng2ord = require('Module:ConvertNumeric').english_to_ordinal
	local th = 'th'
	if not ordinal then
		th = ''
		eng2ord = require('Module:ConvertNumeric').english_to_numeral
	end
	local capitalize = nil ~= string.match(word, '^%u') --determine capitalization
	local nord = eng2ord(string.lower(word)) --operate on/with lowercase, and restore any capitalization later
	
	local lspace = ' ' --assume a leading space (most common)
	local tspace = ' ' --assume a trailing space (most common)
	if string.match(firstpart, '[%-%(]$') then lspace = '' end --DNE for "Straight-eight engines"-type cats
	if string.match(lastpart, '^[%-%)]' ) then tspace = '' end --DNE for "Nine-cylinder engines"-type cats
	
	--sterilize min/max
	local maxword_default = 99
	local maxword = maxword_default
	local minword = 1
	if minimumword then
		local num = tonumber(minimumword)
		if num and 0 < num and num < maxword then
			minword = num
		else
			local ord = eng2ord(minimumword)
			if 0 < ord and ord < maxword then
				minword = ord
			end
		end
	end
	if maximumword then
		local num = tonumber(maximumword)
		if num and 0 < num and num < maxword then
			maxword = num
		else
			local ord = eng2ord(maximumword)
			if 0 < ord and ord < maxword then
				maxword = ord
			end
		end
	end
	if minword > nord then minword = nord end
	if maxword < nord then maxword = nord end
	
	--determine max existing cat
	local listoverride = true
	local n_max = nord
	local m = 1
	while m <= 5 do
		local n = nord + m
		local nth = p.addord(n)
		if not ordinal then nth = n end
		local w = ord2eng{ num = n, ordinal = ordinal, capitalize = capitalize }
		local catlink = catlinkfollowr( frame, firstpart..lspace..w..tspace..lastpart, nth, nil, listoverride )
		if catlink.catexists then n_max = n end
		m = m + 1
	end
	
	--begin navwordinal
	local navw = '<div class="toccolours categorySeriesNavigation-range">\n'
	
	local navlist = {}
	local prepad = ''
	local i = -5 --nav position
	while i <= 5 do
		local n = nord + i
		
		if n >= 1 then
			local nth = p.addord(n)
			if not ordinal then nth = n end
			if i ~= 0 then --left/right navw
				local w = ord2eng{ num = n, ordinal = ordinal, capitalize = capitalize }
				local catlink = catlinkfollowr( frame, firstpart..lspace..w..tspace..lastpart, nth )
				if minword <= n and n <= maxword then
					if catlink.rtarget then --a {{Category redirect}} was followed
						trackcat(24, 'Category series navigation wordinal redirected')
					end
					if n <= n_max or
					   maxword ~= maxword_default
					then
						table.insert(navlist, prepad..catlink.navelement) --display normally
						prepad = ''
					else
						local postpad = '<span style="visibility:hidden"> • '..nth..'</span>'
						navlist[#navlist] = (navlist[#navlist] or '')..postpad
						if listall then tlistall[#tlistall] = tlistall[#tlistall]..' ('..postpad..')' end
					end
				else
					local postpad = '<span style="visibility:hidden"> • '..nth..'</span>'
					navlist[#navlist] = (navlist[#navlist] or '')..postpad
					if listall then tlistall[#tlistall] = tlistall[#tlistall]..' ('..postpad..')' end
				end
			else --center navw
				table.insert(navlist, prepad..'<b>'..nth..'</b>')
				prepad = ''
			end
		else --n < 1
			prepad = prepad..'<span style="visibility:hidden"> • '..'0'..th..'</span>'
			if listall then tlistall[#tlistall] = (tlistall[#tlistall] or '')..' (x)' end
		end
		
		i = i + 1
	end
	-- Add the list
	navw = navw..horizontal(navlist)..'\n'
	
	isolatedcat()
	if listall then
		return listalllinks()
	else
		return navw..'</div>'
	end
end


--[[==========================={{  find_var  }}===============================]]

local function find_var( pn )
	--Extracts the variable text (e.g. 2015, 2015–16, 2000s, 3rd, III, etc.) from a string,
	--and returns { ['vtype'] = <'year'|'season'|etc.>, <v> = <2015|2015–16|etc.> }
	local pagename = currtitle.text
	if pn and pn ~= '' then
		pagename = pn
	end
	
	local cpagename = 'Category:'..pagename --limited-Lua-regex workaround
	
	local d_season = mw.ustring.match(cpagename, ':(%d+s).+%(%d+[–-]%d+%)') --i.e. "1760s in the Province of Quebec (1763–1791)"
	
	local y_season = mw.ustring.match(cpagename, ':(%d+) .+%(%d+[–-]%d+%)') --i.e. "1763 establishments in the Province of Quebec (1763–1791)"
	
	local e_season = mw.ustring.match(cpagename, '%s(%d+[–-])$') or --irreg; ending unknown, e.g. "Members of the Scottish Parliament 2021–"
					 mw.ustring.match(cpagename, '%s(%d+[–-]present)$') --e.g. "UK MPs 2019–present"
	
	local season   = mw.ustring.match(cpagename, '[:%s%(](%d+[–-]%d+)[%)%s]') or --split in 2 b/c you can't frontier '$'/eos?
					 mw.ustring.match(cpagename, '[:%s](%d+[–-]%d+)$')
	
	local tvseason = mw.ustring.match(cpagename, 'season (%d+)') or
					 mw.ustring.match(cpagename, 'series (%d+)')
	
	local nordinal = mw.ustring.match(cpagename, '[:%s](%d+[snrt][tdh])[-%s]') or
					 mw.ustring.match(cpagename, '[:%s](%d+[snrt][tdh])$')
	
	local decade   = mw.ustring.match(cpagename, '[:%s](%d+s)[%s-]') or
					 mw.ustring.match(cpagename, '[:%s](%d+s)$')
	
	local year     = mw.ustring.match(cpagename, '[:%s](%d%d%d%d)%s') or --prioritize 4-digit years
					 mw.ustring.match(cpagename, '[:%s](%d%d%d%d)$') or
					 mw.ustring.match(cpagename, '[:%s](%d+)%s') or
					 mw.ustring.match(cpagename, '[:%s](%d+)$') or
					 --expand/combine exceptions below as needed
					 mw.ustring.match(cpagename, '[:%s](%d+)-related') or
					 mw.ustring.match(cpagename, '[:%s](%d+)-cylinder') or
					 mw.ustring.match(cpagename, '[:%-VW](%d+)%s') --e.g. "Straight-8 engines"
	
	local roman    = mw.ustring.match(cpagename, '%s([IVXLCDM]+)%s')
	
	local found    = d_season or y_season or e_season or season or tvseason or
					 nordinal or decade or year or roman
	
	if found then
		if string.match(found, '%d%d%d%d%d') == nil then
			--return in order of decreasing complexity/chance for duplication
			if nordinal and season --i.e. "18th-century establishments in the Province of Quebec (1763–1791)"
						then return { ['vtype'] = 'nordinal', ['v'] = nordinal } end
			if d_season then return { ['vtype'] = 'decade',   ['v'] = d_season } end
			if y_season then return { ['vtype'] = 'year',     ['v'] = y_season } end
			if e_season then return { ['vtype'] = 'ending',   ['v'] = e_season } end
			if season   then return { ['vtype'] = 'season',   ['v'] = season   } end
			if tvseason then return { ['vtype'] = 'tvseason', ['v'] = tvseason } end
			if nordinal then return { ['vtype'] = 'nordinal', ['v'] = nordinal } end
			if decade   then return { ['vtype'] = 'decade',   ['v'] = decade   } end
			if year     then return { ['vtype'] = 'year',     ['v'] = year     } end
			if roman    then return { ['vtype'] = 'roman',    ['v'] = roman    } end
		end
	else
		--try wordinals ('zeroth' to 'ninety-ninth' only)
		local eng2ord = require('Module:ConvertNumeric').english_to_ordinal
		local split = mw.text.split(pagename, ' ')
		for i=1, #split do
			if eng2ord(split[i]) > -1 then
				return { ['vtype'] = 'wordinal', ['v'] = split[i] }
			end
		end
		
		--try English numerics ('one'/'single' to 'ninety-nine' only)
		local eng2num = require('Module:ConvertNumeric').english_to_numeral
		local split = mw.text.split(pagename, '[%s%-]') --e.g. "Nine-cylinder engines"
		for i=1, #split do
			if eng2num(split[i]) > -1 then
				return { ['vtype'] = 'enumeric', ['v'] = split[i] }
			end
		end
	end
	
	errors = p.errorclass('Function find_var can\'t find the variable text in category "'..pagename..'".')
	return { ['vtype'] = 'error', ['v'] = p.failedcat(errors, 'V') }
end


--[[==========================================================================]]
--[[                                  Main                                    ]]
--[[==========================================================================]]

function p.csn( frame )
	--arg checks & handling
	local args = frame:getParent().args
	checkforunknownparams(args)       --for template args
	checkforunknownparams(frame.args) --for #invoke'd args
	local cat  = args['cat']                --'testcase' alias for catspace
	local list = args['list-all-links']     --debugging utility to output all links & followed #Rs
	local follow = args['follow-redirects'] --default 'yes'
	local testcase    = args['testcase']
	local testcasegap = args['testcasegap']
	local minimum = args['min']
	local maximum = args['max']
	local skip_gaps = args['skip-gaps']
	local show = args['show']
	
	if show and show ~= '' then
		if show == 'skip-gaps'  then return skipgaps_limit
		elseif show == 'term-limit' then return term_limit
		elseif show == 'hgap-limit' then return hgap_limit
		elseif show == 'ygap-limit' then return ygap_limit end
	end
	
	--apply args
	local pagename = testcase or cat or currtitle.text
	local testcaseindent = ''
	if testcasecolon == ':' then testcaseindent = '\n::' end
	if follow and follow == 'no' then followRs = false end
	if list and list == 'yes' then listall = true end
	if skip_gaps and skip_gaps == 'yes' then
		skipgaps = true
		trackcat(26, 'Category series navigation using skip-gaps parameter')
	end
	
	--ns checks
	if currtitle.nsText == 'Category' then
		if cat and cat ~= '' then
			trackcat(1, 'Category series navigation using cat parameter')
		end
		if testcase and testcase ~= '' then
			trackcat(2, 'Category series navigation using testcase parameter')
		end
	elseif currtitle.nsText == '' then
		trackcat(30, 'Category series navigation in mainspace')
	end
	
	--find the variable parts of pagename
	local findvar = find_var(pagename)
	if findvar.vtype == 'error' then --basic format error checking in find_var()
		return findvar.v..table.concat(ttrackingcats)
	end
	local start = string.match(findvar.v, '^%d+')
	
	--the rest is static
	local findvar_escaped = string.gsub( findvar.v, '%-', '%%%-')
	local firstpart, lastpart = string.match(pagename, '^(.-)'..findvar_escaped..'(.*)$')
	if findvar.vtype == 'tvseason' then --double check for cases like "30 Rock (season 3) episodes"
		firstpart, lastpart = string.match(pagename, '^(.-season )'..findvar_escaped..'(.*)$')
		if firstpart == nil then
			firstpart, lastpart = string.match(pagename, '^(.-series )'..findvar_escaped..'(.*)$')
		end
	end
	firstpart = mw.text.trim(firstpart or '')
	lastpart  = mw.text.trim(lastpart or '')

	--call the appropriate nav function, in order of decreasing popularity
	if findvar.vtype == 'year' then     --e.g. "500", "2001"; nav_year..nav_decade; ~75% of cats
		local nav1 = nav_year( frame, firstpart, start, lastpart, minimum, maximum )..testcaseindent..table.concat(ttrackingcats)
		
		local dec = math.floor(findvar.v/10)
		local decadecat = nil
		local firstpart_dec = firstpart
		if firstpart_dec ~= '' then
			firstpart_dec = firstpart_dec..' the'
		elseif firstpart_dec == 'AD' and dec <= 1 then
			firstpart_dec = ''
			if dec == 0 then dec = '' end
		end
		local decade = dec..'0s '
		decadecat = mw.text.trim( firstpart_dec..' '..decade..lastpart )
		local exists = catexists(decadecat)
		if exists then
			navborder = false
			trackcat(28, 'Category series navigation year and decade')
			local nav2 = nav_decade( frame, firstpart_dec, decade, lastpart, minimum, maximum )..testcaseindent..table.concat(ttrackingcats)
			return wrap( nav1, nav2 )
		elseif ttrackingcats[16] ~= '' then --nav_year isolated; check nav_hyphen (e.g. UK MPs 1974, Moldovan MPs 2009, etc.)
			local hyphen = '–'
			local finish = start
			local nav2 = nav_hyphen( frame, start, hyphen, finish, firstpart, lastpart, minimum, maximum, testcasegap )..testcaseindent..table.concat(ttrackingcats)
			if ttrackingcats[16] ~= '' then return wrap( nav1 ) --still isolated; rv to nav_year
			else return wrap( nav2 ) end
		else --regular nav_year
			return wrap( nav1 )
		end
		
	elseif findvar.vtype == 'decade' then   --e.g. "0s", "2010s"; nav_decade..nav_nordinal; ~12% of cats
		local nav1 = nav_decade( frame, firstpart, start, lastpart, minimum, maximum )..testcaseindent..table.concat(ttrackingcats)
		
		local decade = tonumber(string.match(findvar.v, '^(%d+)s'))
		local century = math.floor( ((decade-1)/100) + 1 ) --from {{CENTURY}}
		if century == 0 then century = 1 end --no 0th century
		if string.match(decade, '00$') then
			century = century + 1 --'2000' is in the 20th, but the rest of the 2000s is in the 21st
		end
		local clastpart = ' century '..lastpart
		local centurycat = mw.text.trim( firstpart..' '..p.addord(century)..clastpart )
		local exists = catexists(centurycat)
		if not exists then --check for hyphenated century
			clastpart = '-century '..lastpart
			centurycat = mw.text.trim( firstpart..' '..p.addord(century)..clastpart )
			exists = catexists(centurycat)
		end
		if exists then
			navborder = false
			trackcat(29, 'Category series navigation decade and century')
			local nav2 = nav_nordinal( frame, firstpart, century, clastpart, minimum, maximum )..testcaseindent..table.concat(ttrackingcats)
			return wrap( nav1, nav2 )
		else
			return wrap( nav1 )
		end
		
	elseif findvar.vtype == 'nordinal' then --e.g. "1st", "99th"; ~7.5% of cats
		return wrap( nav_nordinal( frame, firstpart, start, lastpart, minimum, maximum )..testcaseindent..table.concat(ttrackingcats) )
		
	elseif findvar.vtype == 'season' then   --e.g. "1–4", "1999–2000", "2001–02", "2001–2002", "2005–2010", etc.; ~5.25%
		local hyphen, finish = mw.ustring.match(findvar.v, '%d([–-])(%d+)') --ascii 150 & 45 (ndash & keyboard hyphen); mw req'd
		return wrap( nav_hyphen( frame, start, hyphen, finish, firstpart, lastpart, minimum, maximum, testcasegap )..testcaseindent..table.concat(ttrackingcats) )
		
	elseif findvar.vtype == 'tvseason' then --e.g. "1", "15" but preceded with "season" or "series"; <1% of cats
		return wrap( nav_tvseason( frame, firstpart, start, lastpart, maximum )..testcaseindent..table.concat(ttrackingcats) ) --"minimum" defaults to 1
		
	elseif findvar.vtype == 'wordinal' then --e.g. "first", "ninety-ninth"; <<1% of cats
		local ordinal = true
		return wrap( nav_wordinal( frame, firstpart, findvar.v, lastpart, minimum, maximum, ordinal, frame )..testcaseindent..table.concat(ttrackingcats) )
		
	elseif findvar.vtype == 'enumeric' then --e.g. "one", "ninety-nine"; <<1% of cats
		local ordinal = false
		return wrap( nav_wordinal( frame, firstpart, findvar.v, lastpart, minimum, maximum, ordinal, frame )..testcaseindent..table.concat(ttrackingcats) )
		
	elseif findvar.vtype == 'roman' then    --e.g. "I", "XXVIII"; <<1% of cats
		return wrap( nav_roman( frame, firstpart, findvar.v, lastpart, minimum, maximum )..testcaseindent..table.concat(ttrackingcats) )
		
	elseif findvar.vtype == 'ending' then   --e.g. "2021–" (irregular; ending unknown); <<<1% of cats
		local hyphen, finish = mw.ustring.match(findvar.v, '%d([–-])present$'), -1 --ascii 150 & 45 (ndash & keyboard hyphen); mw req'd
		if hyphen == nil then
			hyphen, finish = mw.ustring.match(findvar.v, '%d([–-])$'), 0 --0/-1 are hardcoded switches for nav_hyphen()
		end
		return wrap( nav_hyphen( frame, start, hyphen, finish, firstpart, lastpart, minimum, maximum, testcasegap )..testcaseindent..table.concat(ttrackingcats) )
		
	else                                 --malformed
		errors = p.errorclass('Failed to determine the appropriate nav function from malformed season "'..findvar.v..'". ')
		return p.failedcat(errors, 'N')..table.concat(ttrackingcats)
	end
end

return p