模块:Date table sorting

local yesno = require('Module:Yesno')
local lang = mw.language.getContentLanguage()
local N_YEAR_DIGITS = 12
local MAX_YEAR = 10^N_YEAR_DIGITS - 1

--------------------------------------------------------------------------------
-- Dts class
--------------------------------------------------------------------------------

local Dts = {}

function Dts.GetErrMsgFromOther (frame, args) 
	local msg = ''
	local args = {}
	if frame == mw.getCurrentFrame() then
		msg = frame.args[1] or frame.args['1'] or ''
		args[1] = frame.args[2] or frame.args['2'] or ''
		args[2] = frame.args[3] or frame.args['3'] or ''
		args[3] = frame.args[4] or frame.args['4'] or ''
		args['cat'] = 0
	else
		return Dts.errmsg (frame, args)
	end
	return Dts.errmsg (msg, args)
end

function Dts.errmsg (msg, args) 
	msg = msg or ''
	args = args or {}
	local needcat = args['cat'] or 1

	local function arg (x)
		if args[x] then
			return args[x]
		end
		return ''
	end
	
	local msglist = {
		['error'] = '<strong class="error">[[Module:Date table sorting]]錯誤:%s</strong>',
		
		['valid-err'] = '解析日期格式「%s」時出現未知錯誤。',
		['valid-year'] = '給出的年份「%s」年不合理。',
		['valid-month'] = '給出的月份「%s」月不合理。',
		['valid-day'] = '給出的日「%s」日不合理。',
		['valid-bc'] = '給出的西元前後判斷值「%s」不合理(僅能使用「BC」, 「BCE」, 「AD」 或「CE」)。',
		['valid-date'] = '給出的日期「%s」不合理。',
		['valid-args-addkey'] = '參數<code>addkey</code>的值「%s」不合理。',
		
		['year-zero'] = '年份不得為零。',
		['year-min'] = '給出的年份「%s年」低於最小值%s年。',
		['year-max'] = '給出的年份「%s年」超出最大值%s年。',
		['year-integer'] = '給出的年份「%s年」不是整數。',
		
		['unknown-month'] = '月份只有1月到12月,沒有「第%s月」!',
		['unknown-day-31'] = '%s月只有31天,沒有「第%s天」!',
		['unknown-day-30'] = '%s月只有30天,沒有「第%s天」!',
		['unknown-day-Feb'] = '%s年2月只有%s天,沒有「第%s天」!',
		
		['unknown-format'] = '無法識別格式「%s」。',
		
		['args-addkey'] = '參數<code>addkey</code>的值應介於0到9999之間。'
	}
	return string.format((msglist[msg] or ''), (args[1] or ''), (args[2] or ''), (args[3] or ''), (args[4] or ''))
end

Dts.__index = Dts

Dts.months = {
	"January",
	"February",
	"March",
	"April",
	"May",
	"June",
	"July",
	"August",
	"September",
	"October",
	"November",
	"December"
}

Dts.monthsAbbr = {
	"Jan",
	"Feb",
	"Mar",
	"Apr",
	"May",
	"Jun",
	"Jul",
	"Aug",
	"Sep",
	"Oct",
	"Nov",
	"Dec"
}

function Dts._makeMonthSearch(t)
	local ret = {}
	for i, month in ipairs(t) do
		ret[month:lower()] = i
	end
	return ret
end
Dts.monthSearch = Dts._makeMonthSearch(Dts.months)
Dts.monthSearchAbbr = Dts._makeMonthSearch(Dts.monthsAbbr)
Dts.monthSearchAbbr['sept'] = 9 -- Allow "Sept" to match September

Dts.formats = {
	ymd = true,
	dmy = true,
	mdy = true,
	ym = true,
	dm = true,
	md = true,
	my = true,
	y = true,
	m = true,
	d = true,
	hide = true
}

function Dts.new(args)
	local self = setmetatable({}, Dts)

	-- Parse date parameters.
	-- In this step we also record whether the date was in DMY or YMD format,
	-- and whether the month name was abbreviated.
	if args[2] or args[3] or args[4] then
		self:parseDateParts(args[1], args[2], args[3], args[4])
	elseif args[1] then
		self:parseDate(args[1])
	end

	-- Raise an error on invalid values
	if self.year then
		if self.year == 0 then
			error(self.errmsg('year-zero') , 0)
		elseif self.year < -MAX_YEAR then
			error(self.errmsg('year-min', {self.year, lang:formatNum(-MAX_YEAR)}), 0)
		elseif self.year > MAX_YEAR then
			error(self.errmsg('year-max', {self.year, lang:formatNum(MAX_YEAR)}), 0)
		elseif math.floor(self.year) ~= self.year then
			error(self.errmsg('year-integer', {self.year}), 0)
		end
	end
	if self.month and (
		self.month < 1
		or self.month > 12
		or math.floor(self.month) ~= self.month
	) then
		error(self.errmsg('unknown-month', {self.month}), 0)
	end
	
	if self.day then
		if (
			(self.month == 1) 
			or (self.month == 3)
			or (self.month == 5)
			or (self.month == 7)
			or (self.month == 8)
			or (self.month == 10)
			or (self.month == 12)
		) and (self.day > 31) then
			error(self.errmsg('unknown-day-31', {self.month, self.day}), 0)
		elseif (
			(self.month == 4) 
			or (self.month == 6)
			or (self.month == 9)
			or (self.month == 11)
		) and (self.day > 30) then
			error(self.errmsg('unknown-day-30', {self.month, self.day}), 0)
		elseif (self.month == 2) and (self.day > 29) and (self.year % 400 == 0 or self.year % 4 == 0 and self.year % 100 ~= 0) then
			error(self.errmsg('unknown-day-Feb', {self.year, 29, self.day}), 0)
		elseif (self.month == 2) and (self.day > 28) and not (self.year % 400 == 0 or self.year % 4 == 0 and self.year % 100 ~= 0) then
			error(self.errmsg('unknown-day-Feb', {self.year, 28, self.day}), 0)
		end
	end
	--]=]

	-- Set debug mode
	if args.debug then
		self.isdebug = args.debug
	end

	-- Set the format string
	if args.format then
		self.format = args.format
	else
		self.format = self.format or 'ymd'
	end
	if not Dts.formats[self.format] then
		error(self.errmsg('unknown-format', {tostring(self.format)}), 0)
	end

	-- Set addkey. This adds a value at the end of the sort key, allowing users
	-- to manually distinguish between identical dates.
	if args.addkey then
		self.addkey = tonumber(args.addkey)
		if not self.addkey or
			math.floor(self.addkey) ~= self.addkey
		then
			error(self.errmsg('valid-args-addkey', {args.addkey}), 0)
		elseif self.addkey < 0 or self.addkey > 9999 then
			error(self.errmsg('args-addkey'), 0)
		end
	end

	-- Set whether the displayed date is allowed to wrap or not.
	self.isWrapping = args.nowrap == 'off' or yesno(args.nowrap) == false

	-- Set whether the abbreviated or not.
	self.isAbbreviated = args.abbr == 'on' or yesno(args.abbr) == true

	-- Check for deprecated parameters.
	if args.link then
		self.hasDeprecatedParameters = true
	end

	return self
end

function Dts:hasDate()
	return (self.year or self.month or self.day) ~= nil
end

-- Find the month number for a month name, and set the isAbbreviated flag as
-- appropriate.
function Dts:parseMonthName(s)
	s = s:lower()
	local month = Dts.monthSearch[s]
	if month then
		return month
	else
		month = Dts.monthSearchAbbr[s]
		if month then
			self.isAbbreviated = true
			return month
		end
	end
	return nil
end

-- Parses separate parameters for year, month, day, and era.
function Dts:parseDateParts(year, month, day, bc)
	if year then
		self.year = tonumber(year)
		if not self.year then
			error(self.errmsg('valid-year', {tostring(year)}), 0)
		end
	end
	if month then
		if tonumber(month) then
			self.month = tonumber(month)
		elseif type(month) == 'string' then
			self.month = self:parseMonthName(month)
		end
		if not self.month then
			error(self.errmsg('valid-month', {tostring(month)}), 0)
		end
	end
	if day then
		self.day = tonumber(day)
		if not self.day then
			error(self.errmsg('valid-day', {tostring(day)}))
		end
	end
	if bc then
		local bcLower = type(bc) == 'string' and bc:lower()
		if bcLower == 'bc' or bcLower == 'bce' then
			if self.year and self.year > 0 then
				self.year = -self.year
			end
		elseif bcLower ~= 'ad' and bcLower ~= 'ce' then
			error(self.errmsg('valid-bc', {tostring(bc)}), 0)
		end
	end
end

-- This method parses date strings. This is a poor man's alternative to
-- mw.language:formatDate, but it ends up being easier for us to parse the date
-- here than to use mw.language:formatDate and then try to figure out after the
-- fact whether the month was abbreviated and whether we were DMY or MDY.
function Dts:parseDate(date)
	-- Generic error message.
	local function dateError()
		error(self.errmsg('valid-date', {tostring(date)}), 0)
	end

	local function parseDayOrMonth(s)
		if s:find('^%d%d?$') then
			return tonumber(s)
		end
	end

	local function parseMonth(s)
		if s:find('^%d%d?$') and tonumber(s) >=1 and tonumber(s) <= 12 then
			return tonumber(s)
		end
	end

	local function parseDay(s)
		if self.month then
			lastday = {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
			if s:find('^%d%d?$') and tonumber(s) >=1 and tonumber(s) <= lastday[self.month] then
				return tonumber(s)
			end
		end
	end

	local function parseYear(s)
		if s:find('^%d%d?%d?%d?$') then
			return tonumber(s)
		end
	end

	-- Deal with year-only dates first, as they can have hyphens in, and later
	-- we need to split the string by all non-word characters, including
	-- hyphens. Also, we don't need to restrict years to 3 or 4 digits, as on
	-- their own they can't be confused as a day or a month number.
	self.year = tonumber(date)
	if self.year then
		return
	end

	-- Split the string using non-word characters as boundaries.
	date = tostring(date)
	local parts = mw.text.split(date, '%W+')
	local nParts = #parts
	if parts[1] == '' or parts[nParts] == '' or nParts > 3 then
		-- We are parsing a maximum of three elements, so raise an error if we
		-- have more. If the first or last elements were blank, then the start
		-- or end of the string was a non-word character, which we will also
		-- treat as an error.
		dateError()
	elseif nParts < 1 then
		-- If we have less than one element, then something has gone horribly
		-- wrong.
		error(self.errmsg('valid-err', {tostring(date)}), 0)
	end

	if nParts == 1 then
		-- This can be either a month name or a year.
		self.month = self:parseMonthName(parts[1])
		if not self.month then
			self.year = parseYear(parts[1])
			if not self.year then
				dateError()
			end
		end
	elseif nParts == 2 then
		-- This can be any of the following formats:
		-- DD Month
		-- Month DD
		-- Month YYYY
		-- YYYY-MM
		-- MM-DD
		self.month = self:parseMonthName(parts[1])
		if self.month then
			-- This is either Month DD or Month YYYY.
			self.year = parseYear(parts[2])
			if not self.year then
				-- This is Month DD.
				self.format = 'mdy'
				self.day = parseDayOrMonth(parts[2])
				if not self.day then
					dateError()
				end
			end
		else
			self.month = self:parseMonthName(parts[2])
			if self.month then
				-- This is DD Month.
				self.format = 'ymd'
				self.day = parseDayOrMonth(parts[1])
				if not self.day then
					dateError()
				end
			else
				-- This is MM-DD.
				self.month = parseMonth(parts[1])
				if self.month then
					self.day = parseDay(parts[2])
				else
					-- This is YYYY-MM.
					self.year = parseYear(parts[1])
					self.month = parseMonth(parts[2])
					if not (self.year and self.month) then
						dateError()
					end
				end
			end
		end
	elseif nParts == 3 then
		-- This can be any of the following formats:
		-- DD Month YYYY
		-- Month DD, YYYY
		-- YYYY-MM-DD
		-- DD-MM-YYYY
		self.month = self:parseMonthName(parts[1])
		if self.month then
			-- This is Month DD, YYYY.
			self.format = 'mdy'
			self.day = parseDayOrMonth(parts[2])
			self.year = parseYear(parts[3])
			if not self.day or not self.year then
				dateError()
			end
		else
			self.day = parseDayOrMonth(parts[1])
			if self.day then
				self.month = self:parseMonthName(parts[2])
				if self.month then
					-- This is DD Month YYYY.
					self.format = 'ymd'
					self.year = parseYear(parts[3])
					if not self.year then
						dateError()
					end
				else
					-- This is DD-MM-YYYY.
					self.format = 'ymd'
					self.month = parseDayOrMonth(parts[2])
					self.year = parseYear(parts[3])
					if not self.month or not self.year then
						dateError()
					end
				end
			else
				-- This is YYYY-MM-DD
				self.year = parseYear(parts[1])
				self.month = parseDayOrMonth(parts[2])
				self.day = parseDayOrMonth(parts[3])
				if not self.year or not self.month or not self.day then
					dateError()
				end
			end
		end
	end
end

function Dts:makeSortKey()
	local year, month, day
	local nYearDigits = N_YEAR_DIGITS
	if self:hasDate() then
		year = self.year or os.date("*t").year
		if year < 0 then
			year = -MAX_YEAR - 1 - year
			nYearDigits = nYearDigits + 1 -- For the minus sign
		end
		month = self.month or 1
		day = self.day or 1
	else
		-- Blank {{dts}} transclusions should sort last.
		year = MAX_YEAR
		month = 99
		day = 99
	end
	return string.format(
		'%0' .. nYearDigits .. 'd-%02d-%02d-%04d',
		year, month, day, self.addkey or 0
	)
end

function Dts:getMonthName()
	if not self.month then
		return ''
	end
	if self.isAbbreviated then
		return self.monthsAbbr[self.month]
	else
		return self.months[self.month]
	end
end

function Dts:makeDisplay()
	if self.format == 'hide' then
		return ''
	end
	local hasYear = self.year and self.format:find('y')
	local hasMonth = self.month and self.format:find('m')
	local hasDay = self.day and self.format:find('d')
	local ret = {}
	if hasYear then
		if self.year < 0 then
			ret[#ret + 1] = '公元前'
		end
		local displayYear = math.abs(self.year)
		displayYear = displayYear > 9999 and lang:formatNum(displayYear) or tostring(displayYear)
		ret[#ret + 1] = displayYear
		ret[#ret + 1] = '年'
	end
	if hasMonth then
		ret[#ret + 1] = self.month
		ret[#ret + 1] = '月'
	end
	if hasDay then
		ret[#ret + 1] = self.day
		ret[#ret + 1] = '日'
	end
	return table.concat(ret)
end

function Dts:makeDisplayAbbr()
	if self.format == 'hide' then
		return ''
	end
	local hasYear = self.year and self.format:find('y')
	local hasMonth = self.month and self.format:find('m')
	local hasDay = self.day and self.format:find('d')
	local ret = {}
	if hasYear then
		if self.year < 0 then
			ret[#ret + 1] = 'BC'
		end
		local displayYear = math.abs(self.year)
		displayYear = displayYear > 9999 and lang:formatNum(displayYear) or tostring(displayYear)
		ret[#ret + 1] = displayYear
		if hasMonth or hasDay then
			ret[#ret + 1] = '/'
		end
	end
	if hasMonth then
		ret[#ret + 1] = self.month
		if hasDay then
			ret[#ret + 1] = '/'
		end
	end
	if hasDay then
		ret[#ret + 1] = self.day
	end
	return table.concat(ret)
end

function Dts:makeDisplayAbbrOrNoAbbr()
	if self.isAbbreviated then
		return self:makeDisplayAbbr()
	else
		return self:makeDisplay()
	end
end

function Dts:renderTrackingCategories()
	if self.hasDeprecatedParameters then
		return '[[Category:Template:Date_table_sorting錯誤|廢]]'
	else
		return ''
	end
end

function Dts:__tostring()
	local root = mw.html.create()

	-- Sort key
	if self.isdebug then
		root:tag('span')
			:css('border', 'dotted 1px')
			:wikitext(self:makeSortKey())
	else
		root:tag('span')
			:addClass('sortkey')
			:css('display', 'none')
			:css('speak', 'none')
			:wikitext(self:makeSortKey())
	end
	
	-- Display
	if self:hasDate() then
		if self.isWrapping then
			root:wikitext(self:makeDisplayAbbrOrNoAbbr())
		else
			root:tag('span')
				:css('white-space', 'nowrap')
				:wikitext(self:makeDisplayAbbrOrNoAbbr())
		end
	end

	-- Tracking categories
	root:wikitext(self:renderTrackingCategories())

	return tostring(root)
end

--------------------------------------------------------------------------------
-- Exports
--------------------------------------------------------------------------------

local p = {}

function p._exportClasses()
	return {
		Dts = Dts
	}
end

p.errmsg = Dts.GetErrMsgFromOther

function p._main(args)
	--由於技術問題,無法處理中文的年月日,因此將其丟進 Module:Date_Convert 轉成 ISODate 再傳入,就不會錯
	-- not tonumber(args[1]) 排掉年分,{{fact|可以解析的2000-01-01}}
	if args[1] and not tonumber(args[1]) then
		--iferror預防丟入 Module:Date_Convert 的格式不支援但 Module:Date_table_sorting 支援
		args[1] = mw.getCurrentFrame():callParserFunction('#iferror', require('Module:Date_Convert')._converttime(args[1]), args[1])
	end
	--end
	local success, ret = pcall(function ()
		local dts = Dts.new(args)
		return tostring(dts)
	end)
	if success then
		return ret
	else
		ret = Dts.errmsg('error', {ret})
		if mw.title.getCurrentTitle().namespace == 0 then
			-- Only categorise in the main namespace
			ret = ret .. '[[Category:Template:Date_table_sorting錯誤]]'
		end
		return ret
	end
end

function p.main(frame)
	local args = require('Module:Arguments').getArgs(frame, {
		wrappers = 'Template:Date table sorting',
	})
	return p._main(args)
end

return p