Module:Header/year

--[=[
Construct the year span
--]=]

require('strict')

local p = {} --p stands for package

local yesno = require('Module:Yesno')
local TableTools = require('Module:TableTools')

--[=[
Year properties:

{
	year = integer,
	precision = integer,
	circa = boolean,
	bce = boolean,
	
	uncertain = boolean,
	unknown = boolean,
	unrecognised = boolean,
	
	display = string,
	
	start_year = year,
	end_year = year,
	year_list = year
}
]=]

--[=[ Precision: 
	0 - billion years
	1 - hundred million years,
	2 - ten million years,
	3 - million years,
	4 - hundred thousand years,
	5 - ten thousand years,
	6 - millenia,
	7 - centuries,
	8 - decades,
	9 - years,
	10 - months,
	11 - days
	12 - hours
	13 - minutes
	14 - seconds
]=]

local currentyear = {
	year = tonumber(os.date('%Y')),
	precision = 9,
	circa = false,
	bce = false,
	display = os.date('%Y')
}

local unknownyear = {
	unknown = true,
	display = 'unknown'
}

local function year_less_than(year1, year2)
	if year1 == year2 then
		return false
	elseif not year1 or not year2 then
		return year1 == nil
	elseif year1.bce ~= year2.bce then
		return year1.bce
	end
	if year1.bce then
		return year1.year > year2.year
	else
		return year1.year < year2.year
	end
end

local function year_greater_than(year1, year2)
	if year1 == year2 then
		return false
	elseif not year1 or not year2 then
		return year2 == nil
	elseif year1.bce ~= year2.bce then
		return year2.bce
	end
	if year1.bce then
		return year1.year < year2.year
	else
		return year1.year > year2.year
	end
end

local function year_equal(year1, year2)
	if year1 == year2 then
		return true
	elseif not year1 or not year2 then
		return false
	end
	year1.bce = year2.bce == true
	year1.bce = year2.bce == true
	return year1.bce == year2.bce and year1.year == year2.year
end

local function pad_number(n, pad)
	n = tostring(n)
	return string.rep('0', pad - string.len(n)) .. n
end

local function get_year_text(year, precision, bce)
	if not tonumber(year) then
		return year
	elseif precision < 6 then
		return nil
	end
	
	year = tonumber(year)
	local suffixes = {
		[1] = 'st',
		[2] = 'nd',
		[3] = 'rd',
		['default'] = 'th'
	}
	local bce_text = (bce and ' BCE') or ''
	
	if precision == 6 then
		local millennium = (year - year % 1000)/1000 + 1
		return millennium .. (suffixes[millennium] or suffixes['default']) .. ' millennium' .. bce_text
	elseif precision == 7 then
		local century = (year - year % 100)/100 + 1
		return century .. (suffixes[century] or suffixes['default']) .. ' century' .. bce_text
	elseif precision == 8 then
		local decade = year - year % 10
		return decade .. 's' .. bce_text
	else
		return year .. bce_text
	end
end

local function format_year_for_display(year)
	if not year then
		return nil
	end
	local start_year = year.start_year and TableTools.shallowClone(year.start_year)
	local end_year = year.end_year and TableTools.shallowClone(year.end_year)
	
	if year.year then
		return (year.circa and 'c. ' or '') .. get_year_text(year.year, year.precision + (year.circa and 1 or 0), year.bce) .. ((year.bce == nil and ' CE') or '')
	elseif start_year or end_year then
		if start_year and end_year then
			if start_year.bce and end_year.bce then
				start_year.bce = false
			elseif start_year.bce ~= end_year.bce then
				start_year.bce = start_year.bce or nil
				end_year.bce = end_year.bce or nil
			end
			if start_year.circa then
				end_year.circa = false
			end
		end
		return table.concat({format_year_for_display(start_year) or '', format_year_for_display(end_year) or ''}, '–')
	end
	
	return nil
end

local function format_year_list_for_display(years)
	local year_displays = {}
	for _, year in ipairs(years) do
		table.insert(year_displays, year.display or format_year_for_display(year))
	end
	return table.concat(year_displays, '/')
end

local function substrings_all_equal(years, i)
	local substrings = {}
	for _, year in ipairs(years) do
		table.insert(substrings, string.sub(year, 1, i))
	end
	return #(TableTools.removeDuplicates(substrings)) <= 1
end

local function get_containing_year_category(years_to_categorize)
	local years = {}
	for _, year in ipairs(TableTools.shallowClone(years_to_categorize)) do
		if not year.year or not year.precision then
			return nil
		else
			table.insert(years, year)
		end
	end
	if #years == 0 then
		return nil
	end
	
	local bce = years[1].bce
	for _, year in ipairs(years) do
		year.bce = year.bce == true
		if bce ~= year.bce then
			return nil
		end
	end
	
	local precisions = {}
	for _, year in ipairs(years) do
		table.insert(precisions, year.precision)
	end
	table.sort(precisions)
	
	local n_length = 0
	for _, year in ipairs(years) do
		n_length = math.max(n_length, string.len(year.year))
	end
	
	local padded_years = {}
	for _, year in ipairs(years) do
		table.insert(padded_years, pad_number(year.year, n_length))
	end
	
	local shared_digits = 1
	while shared_digits <= n_length and substrings_all_equal(padded_years, shared_digits) do
		shared_digits = shared_digits + 1
	end
	shared_digits = shared_digits - 1
	local range_precision = math.min(precisions[1], 9 - n_length + shared_digits)
	
	return get_year_text(years[1].year, range_precision, years[1].bce)
end

local function categorise_year(args)
	local cats = {}
	
	if not args.input_year and args.year then
		table.insert(cats, '[[Category:' .. 'Works with year from Wikidata' .. ']]')
	elseif not args.input_year then
		table.insert(cats, '[[Category:' .. 'Undated works' .. ']]')
	elseif args.input_year and not tonumber(args.input_year) then
		table.insert(cats, '[[Category:' .. 'Works with non-numeric dates' .. ']]')
	end
	
	if args.unrecognised then
		table.insert(cats, '[[Category:' .. 'Works with unrecognised dates' .. ']]')
	end
	
	local year = args.year or {}
	local start_year = year.start_year or (year.end_year ~= nil and unknownyear) or {}
	local end_year = year.end_year or (year.start_year ~= nil and currentyear) or {}
	start_year.bce = start_year.bce == true
	end_year.bce = end_year.bce == true
	local year_list = year.year_list or {}
	
	local precisions = {}
	table.insert(precisions, year.precision)
	table.insert(precisions, start_year.precision)
	table.insert(precisions, end_year.precision)
	table.sort(precisions)
	
	if year.uncertain or start_year.uncertain or end_year.uncertain or year.circa or start_year.circa or end_year.circa or #(year_list) > 1 or (precisions[1] and precisions[1] < 9) then
		table.insert(cats, '[[Category:' .. 'Works of uncertain date' .. ']]')
	end
	if year.unknown or start_year.unknown or end_year.unknown then
		table.insert(cats, '[[Category:' .. 'Works of unknown date' .. ']]')
	end
	if start_year.year and end_year.year and year_greater_than(start_year, end_year) then
		table.insert(cats, '[[Category:' .. 'Works with start and end dates in the wrong order' .. ']]')
	end
	
	 -- single year
	local year_for_cat
	if year.year then
		year_for_cat = get_year_text(year.year, year.precision, year.bce)
	-- date range
	elseif start_year.year and end_year.year and start_year.bce == end_year.bce then
		year_for_cat = get_containing_year_category({start_year, end_year})
	elseif #year_list > 0 then
		year_for_cat = get_containing_year_category(year_list)
	end
	table.insert(cats, year_for_cat and ('[[Category:' .. year_for_cat .. ' works]]'))
	
	return table.concat(cats)
end

local function get_year_from_single_statement(statement)
	local snak = statement.mainsnak
	if not snak or not snak.datavalue or not snak.datavalue.value or not snak['datavalue']['value']['time'] or not snak.datavalue.value.precision then
		return nil
	end
	
	local precision = snak.datavalue.value.precision
	if precision < 6 then
		-- precision is less than a millennium
		return {
			precision = precision,
			display = string.gsub(mw.wikibase.formatValue(statement.mainsnak), '^<span>(.*)</span>$', '%1')
		}
	end
	
	local start_years = {}
	local end_years = {}
	local start_year, end_year
	local circa = false
	
	if statement.qualifiers then
		-- Check if date is approximate
		-- P1480 = sourcing circumstances, Q5727902 = circa
		if statement.qualifiers.P1480 then
			for _, qualifier in ipairs(statement.qualifiers.P1480) do
				if qualifier.datavalue and qualifier.datavalue.value.id == 'Q5727902' then
					circa = true
					break
				end
			end
		end
		if circa then
			precision = precision - 1
		end
		
		-- P580 = start time
		if statement.qualifiers.P580 then
			for k, v in pairs(statement.qualifiers.P580) do
				table.insert(start_years, get_year_from_single_statement({mainsnak = v}))
			end
			start_years = TableTools.compressSparseArray(TableTools.removeDuplicates(start_years))
			table.sort(start_years, year_less_than)
			start_year = start_years[1]
			if #start_years > 1 then
				start_year.uncertain = true
			end
		end
		
		-- P582 = end time
		if statement.qualifiers.P582 then
			for k, v in pairs(statement.qualifiers.P582) do
				table.insert(end_years, get_year_from_single_statement({mainsnak = v}))
			end
			end_years = TableTools.compressSparseArray(TableTools.removeDuplicates(end_years))
			table.sort(end_years, year_less_than)
			end_year = end_years[1]
			if #end_years > 1 then
				end_year.uncertain = true
			end
		end
	end
	
	-- extract the year from the timestamp
	-- example timestamps: +2016-10-05T00:00:00Z, -1752-00-00T00:00:00Z
	local year
	local bce = false
	local split = mw.text.split(snak['datavalue']['value']['time'], '-', true)
	if split[1] == '' then
		year = tonumber(split[2])
		bce = true
	else
		year = tonumber(split[1])
	end
	
	-- malformed timestamp
	if not year then
		return nil
	end
	
	local year_data = {
		year = (not start_year and not end_year and year) or nil,
		circa = circa,
		bce = bce,
		uncertain = (start_year and start_year.year ~= year) or circa,
		precision = precision,
		start_year = start_year,
		end_year = end_year
	}
	year_data.display = format_year_for_display(year_data)
	return year_data
end

local function get_wikidata_year(args)
	-- Fetch entity object for Wikidata item connected to the current page
	-- Let manually-specified Wikidata ID override if given and valid
	if not (args.wikidata and mw.wikibase.isValidEntityId(args.wikidata)) then
		args.wikidata = mw.wikibase.getEntityIdForCurrentPage()
	end
	if not args.wikidata then
		return nil
	end
	
	local item = mw.wikibase.getEntity(args.wikidata)
	if not item then
		return nil
	end
	
	local statements = item:getBestStatements('P577') -- publication date
	if #statements == 0 then
		return nil
	end
	
	local years = {}
	for _, statement in ipairs(statements) do
		local year_data = get_year_from_single_statement(statement)
		table.insert(years, year_data)
	end
	
	years = TableTools.compressSparseArray(TableTools.removeDuplicates(years))
	
	if #years == 0 then
		return nil
	elseif #years == 1 then
		return years[1]
	end
	
	return {
		uncertain = true,
		year_list = years,
		display = format_year_list_for_display(years)
	}
end

local function parse_year(year)
	if not year then
		return nil
	elseif year == '?' or string.lower(year) == 'unknown' then
		return unknownyear
	end
	
	local input_year = year
	
	local circa = false
	-- Lua patterns can't do ^c(irca)?(%s|%.|/)* because they don't do alternation or apply quantifiers to groups
	if string.match(year, '^circa') or string.match(year, '^c%.') or string.match(year, '^c%s*/') then
		circa = true
		year = string.gsub(string.gsub(string.gsub(year, '^circa%s*', ''), '^c%.%s*', ''), '^c%s*/%s*', '')
	end
	
	local bce = false
	if string.match(year, 'BC[E]?$') then
		bce = true
		year = string.gsub(year, '%s*BC[E]?$', '')
	end
	
	if string.match(year, '/') then
		local year_split = mw.text.split(year, '/')
		local years = {}
		for _, opt in ipairs(year_split) do
			if opt ~= '' then
				local y = parse_year(opt)
				y.bce = y.bce or bce
				y.circa = y.circa or circa
				table.insert(years, y)
			end
		end
		if #years > 1 then
			return {
				uncertain = true,
				year_list = years,
				display = format_year_list_for_display(years)
			}
		elseif #years == 1 then
			return years[1]
		end
	end
	
	local start_year, end_year
	year = string.gsub(string.gsub(year, '%-', '–'), '—', '–')
	if string.match(year, '^[^–]*–[^–]*$') then
		local year_range = mw.text.split(year, '–')
		start_year = mw.text.trim(year_range[1])
		end_year = mw.text.trim(year_range[2])
		if start_year == '' then
			start_year = nil
		else
			start_year = parse_year(start_year)
			start_year.circa = start_year.circa or circa
			start_year.bce = start_year.bce or bce
		end
		if end_year == '' then
			end_year = nil
		else
			end_year = parse_year(end_year)
			end_year.circa = end_year.circa or circa
			end_year.bce = end_year.bce or bce
		end
	end
	
	local precision
	
	if tonumber(year) then
		precision = 9
	-- Check if it looks like a decade
	elseif string.match(year, '^%d*0s$') then
		precision = 8
		year = string.gsub(year, '^(%d*0)s$', '%1')
	-- Or a century
	elseif string.match(year, '^%d+[a-z]* century$') then
		precision = 7
		year = string.gsub(year, '^(%d+)[a-z]* century$', '%1')
		year = 100 * (tonumber(year) - 1)
	-- Or a millennium
	elseif string.match(year, '^%d+[a-z]* millennium$') then
		precision = 6
		year = string.gsub(year, '^(%d+)[a-z]* millennium', '%1')
		year = 1000 * (tonumber(year) - 1)
	end
	
	if circa and precision then
		precision = precision - 1
	end
	
	if tonumber(year) then
		local data = {
			year = tonumber(year),
			bce = bce,
			circa = circa,
			precision = precision
		}
		data['display'] = format_year_for_display(data)
		return data
	elseif start_year or end_year then
		return {
			start_year = start_year,
			end_year = end_year,
			display = format_year_for_display({start_year = start_year, end_year = end_year})
		}
	end
	
	return {
		unrecognised = true,
		display = input_year
	}
end

function p.construct_year(args)
	local current_title = mw.title.getCurrentTitle()
	
	local year_args = {
		year = args.year,
		noprint = yesno(args.noyear) or false,
		wikidata = args.wikidata
	}
	
	year_args.nocat = yesno(args.noyearcat)
	if year_args.nocat == nil then
		if args.testing then
			year_args.nocat = false
		else
			year_args.nocat = (
				yesno(args.disambiguation) -- disambiguations never categorise
				or not current_title:inNamespaces(0, 114) -- only categorise in mainspace and Translation
				or current_title.isSubpage -- only categorise if this is a base page
			)
		end
	end
	
	local year_data = parse_year(year_args.year) or get_wikidata_year(year_args)
	local cats = (year_args.nocat and '') or categorise_year({input_year = year_args.year, year = year_data})
	if args.testing then
		cats = mw.text.nowiki(cats) .. cats
	end
	
	if year_args.noprint or not year_data or not year_data.display then
		return cats
	end
	
	local year_span = mw.html.create('span')
		:addClass('wst-header-year-text')
		:wikitext(year_data.display)
		:allDone()
	
	return ' (' .. tostring(year_span) .. ')' .. cats
end

return p
Category:Wikisource protected modules