Mô đun:Date table sorting

Bách khoa toàn thư mở Wikipedia
Tài liệu mô đun[tạo]
local yesno = require('Mô đun:Yesno')
local lang = mw.language.getContentLanguage()
local N_YEAR_DIGITS = 12
local MAX_YEAR = 10^N_YEAR_DIGITS - 1

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

local Dts = {}
Dts.__index = Dts

Dts.months = {
	"tháng 1",
	"tháng 2",
	"tháng 3",
	"tháng 4",
	"tháng 5",
	"tháng 6",
	"tháng 7",
	"tháng 8",
	"tháng 9",
	"tháng 10",
	"tháng 11",
	"tháng 12",
	"January",
	"February",
	"March",
	"April",
	"May",
	"June",
	"July",
	"August",
	"September",
	"October",
	"November",
	"December"
}

Dts.monthsAbbr = {
	"Th1",
	"Th2",
	"Th3",
	"Th4",
	"Th5",
	"Th6",
	"Th7",
	"Th8",
	"Th9",
	"Th10",
	"Th11",
	"Th12",
	"Jan",
	"Feb",
	"Mar",
	"Apr",
	"May",
	"Jun",
	"Jul",
	"Aug",
	"Sep",
	"Oct",
	"Nov",
	"Dec"
}

Dts.viMonths = {
	"một",
	"hai",
	"ba",
	"tư",
	"năm",
	"sáu",
	"bảy",
	"tám",
	"chín",
	"mười",
	"mười một",
	"mười hai"
}

Dts._viAltMonth = {
	["giêng"] = "hai",
	["bốn"] = "tư"
}

function Dts._makeMonthSearch(t)
	local ret = {}
	for i, month in ipairs(t) do
		ret[month:lower()] = i > 12 and i - 12 or i
	end
	return ret
end

function Dts._viMonthNameAlt(n)
	return Dts._viAltMonth[n] and Dts._viAltMonth[n] or n
end

Dts.monthSearch = Dts._makeMonthSearch(Dts.months)
Dts.monthSearchAbbr = Dts._makeMonthSearch(Dts.monthsAbbr)
Dts.monthSearchAbbr['sept'] = 9 -- Allow "Sept" to match September
Dts.viMonthSearch = Dts._makeMonthSearch(Dts.viMonths)

Dts.formats = {
	dmy = true,
	mdy = 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('years cannot be zero', 0)
		elseif self.year < -MAX_YEAR then
			error(string.format(
				'years cannot be less than %s',
				lang:formatNum(-MAX_YEAR)
			), 0)
		elseif self.year > MAX_YEAR then
			error(string.format(
				'years cannot be greater than %s',
				lang:formatNum(MAX_YEAR)
			), 0)
		elseif math.floor(self.year) ~= self.year then
			error('years must be an integer', 0)
		end
	end
	if self.month and (
		self.month < 1
		or self.month > 12
		or math.floor(self.month) ~= self.month
	) then
		error('months must be an integer between 1 and 12', 0)
	end
	if self.day and (
		self.day < 1
		or self.day > 31
		or math.floor(self.day) ~= self.day
	) then
		error('days must be an integer between 1 and 31', 0)
	end

	-- Set month abbreviation behaviour, i.e. whether we are outputting
	-- "January" or "Jan".
	if args.abbr then
		self.isAbbreviated = args.abbr == 'on' or yesno(args.abbr) or false
	else
		self.isAbbreviated = self.isAbbreviated or false
	end

	-- Set the format string
	if args.format then
		self.format = args.format
	else
		self.format = self.format or 'mdy'
	end
	if not Dts.formats[self.format] then
		error(string.format(
			"'%s' is not a valid 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
			self.addkey < 0 or
			self.addkey > 9999 or
			math.floor(self.addkey) ~= self.addkey
		then
			error("the 'addkey' parameter must be an integer between 0 and 9999", 0)
		end
	end

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

	-- 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
		else
			month = Dts.viMonthSearch[Dts._viMonthNameAlt(s)]
			if month then
				return month
			end
		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(string.format(
				"'%s' is not a 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(string.format(
				"'%s' is not a valid month",
				tostring(month)
			), 0)
		end
	end
	if day then
		self.day = tonumber(day)
		if not self.day then
			error(string.format(
				"'%s' is not a valid day",
				tostring(day)
			), 0)
		end
	end
	if bc then
		local bcLower = type(bc) == 'string' and bc:lower()
		if bcLower == 'bc' or bcLower == 'bce' or bcLower == 'tcn' then
			if self.year and self.year > 0 then
				self.year = -self.year
			end
		elseif bcLower ~= 'ad' and bcLower ~= 'ce' and bclower ~= 'scn' then
			error(string.format(
				"'%s' is not a valid era code (expected 'BC', 'BCE', 'AD', 'CE', 'TCN' and 'SCN')",
				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(string.format(
			"'%s' is an invalid date",
			date
		), 0)
	end

	local function parseDayOrMonth(s)
		if s:find('^%d%d?$') then
			return tonumber(s)
		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 > 6 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(string.format(
			"an unknown error occurred while parsing the date '%s'",
			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
		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 = 'dmy'
				self.day = parseDayOrMonth(parts[1])
				if not self.day then
					dateError()
				end
			else
				-- This is YYYY-MM.
				self.year = parseYear(parts[1])
				self.month = parseDayOrMonth(parts[2])
				if not self.year or not self.month then
					dateError()
				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 = 'dmy'
					self.year = parseYear(parts[3])
					if not self.year then
						dateError()
					end
				else
					self.format = 'dmy'
					if parts[2]:find("[Tt]háng$") then
						self.format = 'dm'
						self.month = self:parseMonthName(parts[3])
						if not self.month then
							self.month = parseDayOrMonth(parts[3])
							if not self.month then
								dateError()
							end
						end
					else
						-- This is DD-MM-YYYY.
						self.month = parseDayOrMonth(parts[2])
						self.year = parseYear(parts[3])
						if not self.month or not self.year then
							dateError()
						end
					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
	elseif nParts == 4 then
		self.day = parseDayOrMonth(parts[1])
		if self.day then
			self.format = 'dmy'
			self.month = self:parseMonthName(parts[3])
			if not self.month then
				self.month = parseDayOrMonth(parts[3])
			end
			self.year = parseYear(parts[4])
			if not self.month or not self.year then
				if not self.year then
					self.month = self:parseMonthName(parts[3] .. " " .. parts[4])
				else
					dateError()
				end
			end
		else
			if parts[1]:find("[Tt]háng$") then
				self.month = parseDayOrMonth(parts[2])
				self.year = parseYear(parts[4])
				if not self.month or not self.year then
					dateError()
				end
			else
				dateError()
			end
		end
	elseif nParts == 5 then
		self.day = parseDayOrMonth(parts[1])
		if self.day then
			self.format = 'dmy'
			self.month = self:parseMonthName(parts[3] .. " " .. parts[4])
			if not self.month then
				self.month = self:parseMonthName(parts[3])
				if not self.month then
					self.month = parseDayOrMonth(parts[3])
				end
			end
			self.year = parseYear(parts[5])
			if not self.month or not self.year then
				dateError()
			end
		else
			dateError()
		end
	elseif nParts == 6 then
		self.day = parseDayOrMonth(parts[2])
		if self.day then
			self.format = 'dmy'
			self.month = parseDayOrMonth(parts[4])
			self.year = parseYear(parts[6])
			if not self.month or not self.year then
				dateError()
			end
		else
			dateError()
		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 hasDay and hasMonth then
		ret[#ret + 1] = self.day .. ' ' .. self:getMonthName()
	elseif hasDay then
		ret[#ret + 1] = self.day
	elseif hasMonth then
		ret[#ret + 1] = self:getMonthName()
	end
	if hasYear then
		if hasDay or hasMonth then
			ret[#ret + 1] = '&nbsp;năm&nbsp;'
		end
		local displayYear = math.abs(self.year)
		if displayYear > 9999 then
			displayYear = lang:formatNum(displayYear)
		else
			displayYear = tostring(displayYear)
		end
		ret[#ret + 1] = displayYear
		if self.year < 0 then
			ret[#ret + 1] = '&nbsp;TCN'
		end
	end
	return table.concat(ret)
end

function Dts:renderTrackingCategories()
	if self.hasDeprecatedParameters then
		return '[[Thể loại:Dts templates with deprecated parameters]]'
	else
		return ''
	end
end

function Dts:__tostring()
	local root = mw.html.create()
	local span = root:tag('span')
		:attr('data-sort-value', self:makeSortKey())

	-- Display
	if self:hasDate() and self.format ~= 'hide' then
		span:wikitext(self:makeDisplay())
		if not self.isWrapping then
			span:css('white-space', 'nowrap')
		end
	end

	return tostring(root)
end

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

local p = {}

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

function p._main(args)
	local success, ret = pcall(function ()
		local dts = Dts.new(args)
		return tostring(dts)
	end)
	if success then
		return ret
	else
		ret = string.format(
			'<strong class="error">Lỗi trong [[Bản mẫu:Dts]]: %s</strong>',
			ret
		)
		if mw.title.getCurrentTitle().namespace == 0 then
			-- Only categorise in the main namespace
			ret = ret .. '[[Thể loại:Lỗi bản mẫu Dts]]'
		end
		return ret
	end
end

function p.main(frame)
	local args = require('Mô đun:Arguments').getArgs(frame)
	return p._main(args)
end

return p