มอดูล:Date

-- Date functions for use by other modules.
-- I18N and time zones are not supported.
local MINUS = '−'  -- Unicode U+2212 MINUS SIGN
local floor = math.floor
local Date, DateDiff, diffmt  -- forward declarations
local uniq = { 'unique identifier' }
local function is_date(t)
	-- The system used to make a date read-only means there is no unique
	-- metatable that is conveniently accessible to check.
	return type(t) == 'table' and t._id == uniq
end
local function is_diff(t)
	return type(t) == 'table' and getmetatable(t) == diffmt
end
local function _list_join(list, sep)
	return table.concat(list, sep)
end
local function collection()
	-- Return a table to hold items.
	return {
		n = 0,
		add = function (self, item)
			self.n = self.n + 1
			selfself.n = item
		end,
		join = _list_join,
	}
end
local function strip_to_nil(text)
	-- If text is a string, return its trimmed content, or nil if empty.
	-- Otherwise return text (convenient when Date fields are provided from
	-- another module which may pass a string, a number, or another type).
	if type(text) == 'string' then
		text = text:match('(%S.-)%s*$')
	end
	return text
end
local function is_leap_year(year, calname)
	-- Return true if year is a leap year.
	if calname == 'Julian' then
		return year % 4 == 0
	end
	return (year % 4 == 0 and year % 100 ~= 0) or year % 400 == 0
end
local function days_in_month(year, month, calname)
	-- Return number of days (1..31) in given month (1..12).
	if month == 2 and is_leap_year(year, calname) then
		return 29
	end
	return ({ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 })month
end
local function h_m_s(time)
	-- Return hour, minute, second extracted from fraction of a day.
	time = floor(time * 24 * 3600 + 0.5)  -- number of seconds
	local second = time % 60
	time = floor(time / 60)
	return floor(time / 60), time % 60, second
end
local function hms(date)
	-- Return fraction of a day from date's time, where (0 = 0.5 then    -- if at or after midnight of next day
			jdn = jdn + 1
			time = time - 0.5
		else
			time = time + 0.5
		end
		date.hour, date.minute, date.second = h_m_s(time)
	else
		date.second = 0
		date.minute = 0
		date.hour = 0
	end
	local b, c
	if calname == 'Julian' then
		b = 0
		c = jdn + 32082
	else  -- Gregorian
		local a = jdn + 32044
		b = floor((4*a + 3)/146097)
		c = a - floor(146097*b/4)
	end
	local d = floor((4*c + 3)/1461)
	local e = c - floor(1461*d/4)
	local m = floor((5*e + 2)/153)
	date.day = e - floor((153*m + 2)/5) + 1
	date.month = m + 3 - 12*floor(m/10)
	date.year = 100*b + d - 4800 + floor(m/10)
	return true
end
local function fix_numbers(numbers, y, m, d, H, M, S, partial, hastime, calendar)
	-- Put the result of normalizing the given values in table numbers.
	-- The result will have valid m, d values if y is valid; caller checks y.
	-- The logic of PHP mktime is followed where m or d can be zero to mean
	-- the previous unit, and -1 is the one before that, etc.
	-- Positive values carry forward.
	local date
	if not (1 0 }
	'BCMINUS'    = { 'BC'    ,     , isbc = true, sign = MINUS },
	'BCNEGATIVE' = { 'BC'    ,     , isbc = true, sign = '-'   },
	'BC'         = { 'BC'    ,     , isbc = true },
	'B.C.'       = { 'B.C.'  ,     , isbc = true },
	'BCE'        = { 'BCE'   ,     , isbc = true },
	'B.C.E.'     = { 'B.C.E.',     , isbc = true },
	'AD'         = { 'BC'    , 'AD'   },
	'A.D.'       = { 'B.C.'  , 'A.D.' },
	'CE'         = { 'BCE'   , 'CE'   },
	'C.E.'       = { 'B.C.E.', 'C.E.' },
}
local function get_era_for_year(era, year)
	return (era_textera or era_text'BC')year > 0 and 2 or 1 or 
end
local function strftime(date, format, options)
	-- Return date formatted as a string using codes similar to those
	-- in the C strftime library function.
	local sformat = string.format
	local shortcuts = {
		'%c' = '%-I:%M %p %-d %B %-Y %{era}',  -- date and time: 2:30 pm 1 April 2016
		'%x' = '%-d %B %-Y %{era}',            -- date:          1 April 2016
		'%X' = '%-I:%M %p',                    -- time:          2:30 pm
	}
	if shortcutsformat then
		format = shortcutsformat
	end
	local codes = {
		a = { field = 'dayabbr' },
		A = { field = 'dayname' },
		b = { field = 'monthabbr' },
		B = { field = 'monthname' },
		u = { fmt = '%d'  , field = 'dowiso' },
		w = { fmt = '%d'  , field = 'dow' },
		d = { fmt = '%02d', fmt2 = '%d', field = 'day' },
		m = { fmt = '%02d', fmt2 = '%d', field = 'month' },
		Y = { fmt = '%04d', fmt2 = '%d', field = 'year' },
		H = { fmt = '%02d', fmt2 = '%d', field = 'hour' },
		M = { fmt = '%02d', fmt2 = '%d', field = 'minute' },
		S = { fmt = '%02d', fmt2 = '%d', field = 'second' },
		j = { fmt = '%03d', fmt2 = '%d', field = 'dayofyear' },
		I = { fmt = '%02d', fmt2 = '%d', field = 'hour', special = 'hour12' },
		p = { field = 'hour', special = 'am' },
	}
	options = make_option_table(options, date.options)
	local amopt = options.am
	local eraopt = options.era
	local function replace_code(spaces, modifier, id)
		local code = codesid
		if code then
			local fmt = code.fmt
			if modifier == '-' and code.fmt2 then
				fmt = code.fmt2
			end
			local value = datecode.field
			if not value then
				return nil  -- an undefined field in a partial date
			end
			local special = code.special
			if special then
				if special == 'hour12' then
					value = value % 12
					value = value == 0 and 12 or value
				elseif special == 'am' then
					local ap = ({
						'a.m.' = { 'a.m.', 'p.m.' },
						'AM' = { 'AM', 'PM' },
						'A.M.' = { 'A.M.', 'P.M.' },
					})ampm_optionsamopt or { 'am', 'pm' }
					return (spaces ==  and  or ' ') .. (value = 0 then
						sign = 
					else
						value = -value
					end
				end
				return spaces .. sign .. sformat(fmt, value)
			end
			return spaces .. (fmt and sformat(fmt, value) or value)
		end
	end
	local function replace_property(spaces, id)
		if id == 'era' then
			-- Special case so can use local era option.
			local result = get_era_for_year(eraopt, date.year)
			if result ==  then
				return 
			end
			return (spaces ==  and  or ' ') .. result
		end
		local result = dateid
		if type(result) == 'string' then
			return spaces .. result
		end
		if type(result) == 'number' then
			return  spaces .. tostring(result)
		end
		if type(result) == 'boolean' then
			return  spaces .. (result and '1' or '0')
		end
		-- This occurs if id is an undefined field in a partial date, or is the name of a function.
		return nil
	end
	local PERCENT = '\127PERCENT\127'
	return (format
		:gsub('%%%%', PERCENT)
		:gsub('(%s*)%%{(%w+)}', replace_property)
		:gsub('(%s*)%%(%-?)(%a)', replace_code)
		:gsub(PERCENT, '%%')
	)
end
local function _date_text(date, fmt, options)
	-- Return a formatted string representing the given date.
	if not is_date(date) then
		error('date:text: need a date (use "date:text()" with a colon)', 2)
	end
	if type(fmt) == 'string' and fmt:match('%S') then
		if fmt:find('%', 1, true) then
			return strftime(date, fmt, options)
		end
	elseif date.partial then
		fmt = date.month and 'my' or 'y'
	else
		fmt = 'dmy'
		if date.hastime then
			fmt = (date.second > 0 and 'hms ' or 'hm ') .. fmt
		end
	end
	local function bad_format()
		-- For consistency with other format processing, return given format
		-- (or cleaned format if original was not a string) if invalid.
		return mw.text.nowiki(fmt)
	end
	if date.partial then
		-- Ignore days in standard formats like 'ymd'.
		if fmt == 'ym' or fmt == 'ymd' then
			fmt = date.month and '%Y-%m %{era}' or '%Y %{era}'
		elseif fmt == 'my' or fmt == 'dmy' or fmt == 'mdy' then
			fmt = date.month and '%B %-Y %{era}' or '%-Y %{era}'
		elseif fmt == 'y' then
			fmt = date.month and '%-Y %{era}' or '%-Y %{era}'
		else
			return bad_format()
		end
		return strftime(date, fmt, options)
	end
	local function hm_fmt()
		local plain = make_option_table(options, date.options).bydefault.am
		return plain and '%H:%M' or '%-I:%M %p'
	end
	local need_time = date.hastime
	local t = collection()
	for item in fmt:gmatch('%S+') do
		local f
		if item == 'hm' then
			f = hm_fmt()
			need_time = false
		elseif item == 'hms' then
			f = '%H:%M:%S'
			need_time = false
		elseif item == 'ymd' then
			f = '%Y-%m-%d %{era}'
		elseif item == 'mdy' then
			f = '%B %-d, %-Y %{era}'
		elseif item == 'dmy' then
			f = '%-d %B %-Y %{era}'
		else
			return bad_format()
		end
		t:add(f)
	end
	fmt = t:join(' ')
	if need_time then
		fmt = hm_fmt() .. ' ' .. fmt
	end
	return strftime(date, fmt, options)
end
local day_info = {
	-- 0=Sun to 6=Sat
	0 = { 'อา', 'วันอาทิตย์' },
	{ 'จ', 'วันจันทร์' },
	{ 'อ', 'วันอังคาร' },
	{ 'พ', 'วันพุธ' },
	{ 'พฤ', 'วันพฤหัสบดี' },
	{ 'ศ', 'วันศุกร์' },
	{ 'ส', 'วันเสาร์' },
}
local month_info = {
	-- 1=Jan to 12=Dec
	{ 'ม.ค.', 'มกราคม' },
	{ 'ก.พ.', 'กุมภาพันธ์' },
	{ 'มี.ค.', 'มีนาคม' },
	{ 'เม.ย.', 'เมษายน' },
	{ 'พ.ค.', 'พฤษภาคม' },
	{ 'มิ.ย.', 'มิถุนายน' },
	{ 'ก.ค.', 'กรกฎาคม' },
	{ 'ส.ค.', 'สิงหาคม' },
	{ 'ก.ย.', 'กันยายน' },
	{ 'ต.ค.', 'ตุลาคม' },
	{ 'พ.ย.', 'พฤศจิกายน' },
	{ 'ธ.ค.', 'ธันวาคม' },
}
local function name_to_number(text, translate)
	if type(text) == 'string' then
		return translatetext:lower()
	end
end
local function day_number(text)
	return name_to_number(text, {
		sun = 0, sunday = 0,
		mon = 1, monday = 1,
		tue = 2, tuesday = 2,
		wed = 3, wednesday = 3,
		thu = 4, thursday = 4,
		fri = 5, friday = 5,
		sat = 6, saturday = 6,
	})
end
local function month_number(text)
	return name_to_number(text, {
		jan = 1, january = 1,
		feb = 2, february = 2,
		mar = 3, march = 3,
		apr = 4, april = 4,
		may = 5,
		jun = 6, june = 6,
		jul = 7, july = 7,
		aug = 8, august = 8,
		sep = 9, september = 9, sept = 9,
		oct = 10, october = 10,
		nov = 11, november = 11,
		dec = 12, december = 12,
	})
end
local function _list_text(list, fmt)
	-- Return a list of formatted strings from a list of dates.
	if not type(list) == 'table' then
		error('date:list:text: need "list:text()" with a colon', 2)
	end
	local result = { join = _list_join }
	for i, date in ipairs(list) do
		resulti = date:text(fmt)
	end
	return result
end
local function _date_list(date, spec)
	-- Return a possibly empty numbered table of dates meeting the specification.
	-- Dates in the list are in ascending order (oldest date first).
	-- The spec should be a string of form "  "
	-- where each item is optional and
	--   count = number of items wanted in list
	--   day = abbreviation or name such as Mon or Monday
	--   op = >, >=,  meaning after date)
	-- If no count is given, the list is for the specified days in date's month.
	-- The default day is date's day.
	-- The spec can also be a positive or negative number:
	--   -5 is equivalent to '5 '
	if not is_date(date) then
		error('date:list: need a date (use "date:list()" with a colon)', 2)
	end
	local list = { text = _list_text }
	if date.partial then
		return list
	end
	local count, offset, operation
	local ops = {
		'>=' = { before = false, include = true  },
		'>'  = { before = false, include = false },
		'=*)%s*$')
			if not num then
				return list
			end
			if num ~=  then
				count = tonumber(num)
			end
			if day ~=  then
				local dow = day_number(day:gsub('sS$', ))  -- accept plural days
				if not dow then
					return list
				end
				offset = dow - date.dow
			end
			operation = opsop
		else
			return list
		end
	end
	offset = offset or 0
	operation = operation or ops'>'
	local datefrom, dayfirst, daylast
	if operation.before then
		if offset > 0 or (offset == 0 and not operation.include) then
			offset = offset - 7
		end
		if count then
			if count > 1 then
				offset = offset - 7*(count - 1)
			end
			datefrom = date + offset
		else
			daylast = date.day + offset
			dayfirst = daylast % 7
			if dayfirst == 0 then
				dayfirst = 7
			end
		end
	else
		if offset  0 then
				y = -y
			end
			if y  0 or M > 0 or S > 0 then
				date.hour = H
				date.minute = M
				date.second = S
			end
			return date, options
		end
		return
	end
	local function extract_ymd(item)
		-- Called when no day or month has been set.
		local y, m, d = item:match('^(%d%d%d%d)%-(%w+)%-(%d%d?)$')
		if y then
			if date.year then
				return
			end
			if m:match('^%d%d?$') then
				m = tonumber(m)
			else
				m = month_number(m)
			end
			if m then
				date.year = tonumber(y)
				date.month = m
				date.day = tonumber(d)
				return true
			end
		end
	end
	local function extract_day_or_year(item)
		-- Called when a day would be valid, or
		-- when a year would be valid if no year has been set and partial is set.
		local number, suffix = item:match('^(%d%d?%d?%d?)(.*)$')
		if number then
			local n = tonumber(number)
			if #number  12 then
				y = y + 1
				m = m - 12
			elseif m = 30 then
					inc_hour = true
				end
			elseif code == 'dhm' or code == 'hm' then
				if S >= 30 then
					M = M + 1
					if M >= 60 then
						M = 0
						inc_hour = true
					end
				end
			elseif code == 'M' then
				if S >= 30 then
					M = M + 1
				end
			else
				-- Nothing needed because S is an integer.
			end
			if inc_hour then
				H = H + 1
				if H >= 24 then
					H = 0
					days = days + 1
				end
			end
		end
		if code == 'dh' or code == 'dhm' or code == 'dhms' then
			if code == 'dh' then
				return days, H
			elseif code == 'dhm' then
				return days, H, M
			else
				return days, H, M, S
			end
		end
		local hours = days * 24 + H
		if code == 'h' then
			return hours
		elseif code == 'hm' then
			return hours, M
		elseif code == 'M' or code == 's' then
			M = hours * 60 + M
			if code == 'M' then
				return M
			end
			return M * 60 + S
		end
		return hours, M, S
	end
	if wantround then
		local inc_hour
		if code == 'ymdh' or code == 'ymwdh' then
			if M >= 30 then
				inc_hour = true
			end
		elseif code == 'ymdhm' or code == 'ymwdhm' then
			if S >= 30 then
				M = M + 1
				if M >= 60 then
					M = 0
					inc_hour = true
				end
			end
		elseif code == 'ymd' or code == 'ymwd' or code == 'yd' or code == 'md' then
			if H >= 12 then
				extra_days = extra_days + 1
			end
		end
		if inc_hour then
			H = H + 1
			if H >= 24 then
				H = 0
				extra_days = extra_days + 1
			end
		end
	end
	local y, m, d = diff.years, diff.months, diff.days
	if extra_days > 0 then
		d = d + extra_days
		if d > 28 or code == 'yd' then
			-- Recalculate in case have passed a month.
			diff = diff.date1 + extra_days - diff.date2
			y, m, d = diff.years, diff.months, diff.days
		end
	end
	if code == 'ymd' then
		return y, m, d
	elseif code == 'yd' then
		if y > 0 then
			-- It is known that diff.date1 > diff.date2.
			diff = diff.date1 - (diff.date2 + (y .. 'y'))
		end
		return y, floor(diff.age_days)
	elseif code == 'md' then
		return y * 12 + m, d
	elseif code == 'ym' or code == 'm' then
		if wantround then
			if d >= 16 then
				m = m + 1
				if m >= 12 then
					m = 0
					y = y + 1
				end
			end
		end
		if code == 'ym' then
			return y, m
		end
		return y * 12 + m
	elseif code == 'ymw' then
		local weeks = floor(d/7)
		if wantround then
			local days = d % 7
			if days > 3 or (days == 3 and H >= 12) then
				weeks = weeks + 1
			end
		end
		return y, m, weeks
	elseif code == 'ymwd' then
		return y, m, floor(d/7), d % 7
	elseif code == 'ymdh' then
		return y, m, d, H
	elseif code == 'ymwdh' then
		return y, m, floor(d/7), d % 7, H
	elseif code == 'ymdhm' then
		return y, m, d, H, M
	elseif code == 'ymwdhm' then
		return y, m, floor(d/7), d % 7, H, M
	end
	if code == 'y' then
		if wantround and m >= 6 then
			y = y + 1
		end
		return y
	end
	return nil
end
local function _diff_duration(diff, code, options)
	if type(options) ~= 'table' then
		options = { round = options }
	end
	options.duration = true
	return _diff_age(diff, code, options)
end
-- Metatable for some operations on date differences.
diffmt = {  -- for forward declaration above
	__concat = function (lhs, rhs)
		return tostring(lhs) .. tostring(rhs)
	end,
	__tostring = function (self)
		return tostring(self.age_days)
	end,
	__index = function (self, key)
		local value
		if key == 'age_days' then
			if rawget(self, 'partial') then
				local function jdz(date)
					return (date.partial and date.partial.first or date).jdz
				end
				value = jdz(self.date1) - jdz(self.date2)
			else
				value = self.date1.jdz - self.date2.jdz
			end
		end
		if value ~= nil then
			rawset(self, key, value)
			return value
		end
	end,
}
function DateDiff(date1, date2, options)  -- for forward declaration above
	-- Return a table with the difference between two dates (date1 - date2).
	-- The difference is negative if date1 is older than date2.
	-- Return nothing if invalid.
	-- If d = date1 - date2 then
	--     date1 = date2 + d
	-- If date1 >= date2 and the dates have no H:M:S time specified then
	--     date1 = date2 + (d.years..'y') + (d.months..'m') + d.days
	-- where the larger time units are added first.
	-- The result of Date(2015,1,x) + '1m' is Date(2015,2,28) for
	-- x = 28, 29, 30, 31. That means, for example,
	--     d = Date(2015,3,3) - Date(2015,1,31)
	-- gives d.years, d.months, d.days = 0, 1, 3 (excluding date1).
	if not (is_date(date1) and is_date(date2) and date1.calendar == date2.calendar) then
		return
	end
	local wantfill
	if type(options) == 'table' then
		wantfill = options.fill
	end
	local isnegative = false
	local iszero = false
	if date1 = date2 (period is from date2 to date1).
	if date1.partial or date2.partial then
		-- Two partial dates might have timelines:
		---------------------A=================B--- date1 is from A to B inclusive
		--------C=======D-------------------------- date2 is from C to D inclusive
		-- date1 > date2 iff A > C (date1.partial.first > date2.partial.first)
		-- The periods can overlap ('April 2001' - '2001'):
		-------------A===B------------------------- A=2001-04-01  B=2001-04-30
		--------C=====================D------------ C=2001-01-01  D=2001-12-31
		if wantfill then
			date1, date2 = autofill(date1, date2)
		else
			local function zdiff(date1, date2)
				local diff = date1 - date2
				if diff.isnegative then
					return date1 - date1  -- a valid diff in case we call its methods
				end
				return diff
			end
			local function getdate(date, which)
				return date.partial and date.partialwhich or date
			end
			local maxdiff = zdiff(getdate(date1, 'last'), getdate(date2, 'first'))
			local mindiff = zdiff(getdate(date1, 'first'), getdate(date2, 'last'))
			local years, months
			if maxdiff.years == mindiff.years then
				years = maxdiff.years
				if maxdiff.months == mindiff.months then
					months = maxdiff.months
				else
					months = { mindiff.months, maxdiff.months }
				end
			else
				years = { mindiff.years, maxdiff.years }
			end
			return setmetatable({
				date1 = date1,
				date2 = date2,
				partial = {
					years = years,
					months = months,
					maxdiff = maxdiff,
					mindiff = mindiff,
				},
				isnegative = isnegative,
				iszero = iszero,
				age = _diff_age,
				duration = _diff_duration,
			}, diffmt)
		end
	end
	local y1, m1 = date1.year, date1.month
	local y2, m2 = date2.year, date2.month
	local years = y1 - y2
	local months = m1 - m2
	local d1 = date1.day + hms(date1)
	local d2 = date2.day + hms(date2)
	local days, time
	if d1 >= d2 then
		days = d1 - d2
	else
		months = months - 1
		-- Get days in previous month (before the "to" date) given December has 31 days.
		local dpm = m1 > 1 and days_in_month(y1, m1 - 1, date1.calendar) or 31
		if d2 >= dpm then
			days = d1 - hms(date2)
		else
			days = dpm - d2 + d1
		end
	end
	if months < 0 then
		years = years - 1
		months = months + 12
	end
	days, time = math.modf(days)
	local H, M, S = h_m_s(time)
	return setmetatable({
		date1 = date1,
		date2 = date2,
		partial = false,  -- avoid index lookup
		years = years,
		months = months,
		days = days,
		hours = H,
		minutes = M,
		seconds = S,
		isnegative = isnegative,
		iszero = iszero,
		age = _diff_age,
		duration = _diff_duration,
	}, diffmt)
end
return {
	_current = current,
	_Date = Date,
	_days_in_month = days_in_month,
}