మాడ్యూల్:Time

వికీపీడియా నుండి
Jump to navigation Jump to search
Module documentation[create]
require ('Module:No globals')
local getArgs = require ('Module:Arguments').getArgs

local tz = {};																	-- holds local copy of the specified timezone table from tz_data{} 


--[[--------------------------< I S _ S E T >------------------------------------------------------------------

Whether variable is set or not.  A variable is set when it is not nil and not empty.

]]

local function is_set( var )
	return not (var == nil or var == '');
end


--[[--------------------------< D E C O D E _ D S T _ E V E N T >----------------------------------------------

extract ordinal, day-name, and month from daylight saving start/end definition string as digits:
	Second Sunday in March
returns
	2 0 3

Casing doesn't matter but the form of the string does:
	<ordinal> <day> <any single word> <month> – all are separated by spaces

]]

local function decode_dst_event (dst_event_string)
	local ord, day, month;
	
	local ordinals = {['1st'] = 1, ['first'] = 1, ['2nd'] = 2, ['second'] = 2, ['3rd'] = 3, ['third'] = 3, ['4th'] = 4, ['fourth'] = 4, ['5th'] = 5, ['fifth'] = 5, ['last'] = -1};
	local days = {['sunday'] = 0, ['monday'] = 1, ['tuesday'] = 2, ['wednesday'] = 3, ['thursday'] = 4, ['friday'] = 5, ['saturday'] = 6};
	local months = {['january'] = 1, ['february'] = 2, ['march'] = 3, ['april'] = 4, ['may'] = 5, ['june'] = 6,
		['july'] = 7, ['august'] = 8, ['september'] = 9, ['october'] = 10, ['november'] = 11, ['december'] = 12};
	
	dst_event_string = dst_event_string:lower();								-- force the string to lower case because that is how the tables above are indexed
	ord, day, month = dst_event_string:match ('([%a%d]+)%s+(%a+)%s+%a+%s+(%a+)');
	
	if not (is_set (ord) and is_set (day) and is_set (month)) then				-- if one or more of these not set, then pattern didn't match
		return nil;
	end
	
	return ordinals[ord], days[day], months[month];
end


--[[--------------------------< G E T _ D A Y S _ I N _ M O N T H >--------------------------------------------

Returns the number of days in the month where month is a number 1–12 and year is four-digit Gregorian calendar.
Accounts for leap year.

]]

local function get_days_in_month (year, month)
	local days_in_month = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
	
	year = tonumber (year);														-- force these to be numbers just in case
	month = tonumber (month);

	if (2 == month) then														-- if February
		if (0 == (year%4) and (0 ~= (year%100) or 0 == (year%400))) then		-- is year a leap year?
			return 29;															-- if leap year then 29 days in February
		end
	end
	return days_in_month [month];
end


--[[--------------------------< G E T _ D S T _ M O N T H _ D A Y >--------------------------------------------

Return the date (month and day of the month) for the day that is the ordinal (nth) day-name in month (second
Friday in June) of the current year

timestamp is today's date-time number from os.time(); used to supply year
timezone is the timezone parameter value from the template call

Equations used in this function taken from Template:Weekday_in_month

]]

local function get_dst_month_day (timestamp, start)
	local ord, weekday_num, month;
	local first_day_of_dst_month_num;
	local last_day_of_dst_month_num;
	local days_in_month;
	local year;

	if true == start then
		ord, weekday_num, month = decode_dst_event (tz.dst_begins);				-- get start string and convert to digits
	else
		ord, weekday_num, month = decode_dst_event (tz.dst_ends);				-- get end string and convert to digits
	end
	
	if not (is_set (ord) and is_set (weekday_num) and is_set (month)) then
		return nil;																-- could not decode event string
	end
	
	year = os.date ('%Y', timestamp);

	if -1 == ord then		-- j = t + 7×(n + 1) - (wt - w) mod 7				-- if event occurs on the last day-name of the month ('last Sunday of October')
		days_in_month = get_days_in_month (year, month);
		last_day_of_dst_month_num =  os.date ('%w', os.time ({['year']=year, ['month']=month, ['day']=days_in_month}));
		return month, days_in_month + 7*(ord + 1) - ((last_day_of_dst_month_num - weekday_num) % 7);
	else	-- j = 7×n - 6 + (w - w1) mod 7
		first_day_of_dst_month_num = os.date ('%w', os.time ({['year']=year, ['month']=month, ['day']=1}))
		return month, 7 * ord - 6 + (weekday_num - first_day_of_dst_month_num) % 7;		-- return month and calculated date
	end
end


--[[--------------------------< G E T _ U T C _ O F F S E T >--------------------------------------------------

Get utc offset in hours and minutes, convert to seconds.  If the offset can't be converted return nil.
TODO: return error message?
TODO: limit check this? +/-n hours?
]]

local function get_utc_offset ()
	local sign;
	local hours;
	local minutes;
	
	sign, hours, minutes = mw.ustring.match (tz.utc_offset, '([%+%-±−]?)(%d%d):(%d%d)');

	if '-' == sign then sign = -1; else sign = 1; end
	if is_set (hours) and is_set (minutes) then
		return sign * ((hours * 3600) + (minutes * 60));
	else
		return nil;																-- we require that all timezone table have what appears to be a valid offset
	end
end


--[[--------------------------< M A K E _ D S T _ T I M E S T A M P S >----------------------------------------

Return UTC timestamps for the date/time of daylight saving time events (beginning and ending).  These timestamps
will be compared to current UTC time.  A dst timestamp is the date/time in seconds UTC for the timezone at the
hour of the dst event.

For dst rules that specify local event times, the timestamp is the sum of:
	timestamp = current year + dst_month + dst_day + dst_time (all in seconds) local time
Adjust local time to UTC by subtracting utc_offset:
	timestamp = timestamp - utc_offset (in seconds)
For dst_end timestamp, subtract an hour for DST
	timestamp = timestamp - 3600 (in seconds)

For dst rules that specify utc event time the process is the same except that utc offset is not subtracted.

]]

local function make_dst_timestamps (timestamp)
	local dst_begin, dst_end;													-- dst begin and end time stamps 
	local year;																	-- current year
	local dst_b_month, dst_e_month, dst_day;									-- month and date of dst event
	local dst_hour, dst_minute;													-- hour and minute of dst event on year-dst_month-dst_day
	local invert = false;														-- flag to pass on when dst_begin month is numerically larger than dst_end month (southern hemisphere)
	local utc_offset;
	local utc_flag;

	year = os.date ('%Y', timestamp);											-- current year
	utc_offset = get_utc_offset ();												-- in seconds
	if not is_set (utc_offset) then												-- utc offset is a required timezone property
		return nil;
	end

	dst_b_month, dst_day = get_dst_month_day (timestamp, true);					-- month and day that dst begins
	if not is_set (dst_b_month) then
		return nil;
	end
	
	dst_hour, dst_minute = tz.dst_time:match ('(%d%d):(%d%d)');					-- get dst time
	utc_flag = tz.dst_time:find ('[Uu][Tt][Cc]%s*$');							-- set flag when dst events occur at a specified utc time

	dst_begin = os.time ({['year'] = year, ['month'] = dst_b_month, ['day'] = dst_day, ['hour'] = dst_hour, ['min'] = dst_minute});	-- form start timestamp
	if not is_set (utc_flag) then												-- if dst events are specified to occur at local time
		dst_begin = dst_begin - utc_offset;										-- adjust local time to utc by subtracting utc offset
	end

	dst_e_month, dst_day = get_dst_month_day (timestamp, false);				-- month and day that dst ends
	if not is_set (dst_e_month) then
		return nil;
	end
	
	if is_set (tz.dst_e_time) then
		dst_hour, dst_minute = tz.dst_e_time:match ('(%d%d):(%d%d)');			-- get ending dst time; this one for those locales that use different start and end times
		utc_flag = tz.dst_e_time:find ('[Uu][Tt][Cc]%s*$');						-- set flag if dst is pegged to utc time
	end	

	dst_end = os.time ({['year'] = year, ['month'] = dst_e_month, ['day'] = dst_day, ['hour'] = dst_hour, ['min'] = dst_minute});	-- form end timestamp
	if not is_set (utc_flag) then												-- if dst events are specified to occur at local time
		dst_end = dst_end - 3600;												-- assume that local end time is DST so adjust to local ST
		dst_end = dst_end - utc_offset;											-- adjust local time to utc by subtracting utc offset
	end


	if dst_b_month > dst_e_month then
		invert = true;															-- true for southern hemisphere eg: start September YYYY end April YYYY+1
	end

	return dst_begin, dst_end, invert;
end


--[[--------------------------< G E T _ T E S T _ T I M E >----------------------------------------------------

decode ISO formatted date/time into a table suitable for os.time().  For testing, this time is utc just as is
returned by the os.time() function.

]]

local function get_test_time (iso_date)
	local year, month, day, hour, minute, second;

	year, month, day, hour, minute, second = iso_date:match ('(%d%d%d%d)\-(%d%d)\-(%d%d)T(%d%d):(%d%d):(%d%d)');
	if not year then
		return nil;																-- test time did not match the specified pattern
	end
	return {['year'] = year, ['month'] = month, ['day'] = day, ['hour'] = hour, ['min'] = minute, ['sec'] = second};
end


--[=[-------------------------< T I M E >----------------------------------------------------------------------

This template takes several parameters; none are required:
	1. the time zone abbreviation (positional, always the first unnamed parameter)
	2. a date format flag; second positional parameter or |df=; can have one of several assigned values:
		y – display output time in dmy format
		dmy – same as 'y'
		mdy – default; included for completeness
		iso – display output time in YYYY-MM-DDTHH:mm format
	3. |dst= when set to 'no' disables dst calculations for locations that do not observe dst – Arizona in MST
	4. |_TEST_TIME_= a specific utc time in ISO date time format used for testing this code
	
TODO: convert _TEST_TIME_ to |time=?

Timezone abbreviations can be found here: [[List_of_time_zone_abbreviations]]

]=]

local function time (frame)
	local args = getArgs (frame);
	local utc_timestamp, timestamp;												-- current or _TEST_TIME_ timestamps; timestamp is local ST or DST time used in output
	local dst_begin_ts, dst_end_ts;												-- DST begin and end timestamps in UTC
	local tz_abbr;																-- select ST or DST timezone abbreviaion used in output 
	local time_string;															-- holds output time/date in |df= format
	local utc_offset;
	local invert;																-- true when southern hemisphere
	local df;																	-- date format flag; the |df= parameter

	local timeonly = 'yes' == args.timeonly;									-- boolean
	local dateonly = 'yes' == args.dateonly;									-- boolean
	local hide_refresh = 'yes' == args['hide-refresh'];							-- boolean
	local hide_tz = 'yes' == args['hide-tz'];									-- boolean
	local unlink_tz = 'yes' == args['unlink-tz'];								-- boolean

	if timeonly and dateonly then												-- invalid condition when both are set
		timeonly, dateonly = false;
	end
	
	local tz_data = table.concat ({'Module:Time/data', frame:getTitle():find('sandbox', 1, true) and '/sandbox' or ''}); -- make a data module name; sandbox or live
	tz_data =  mw.loadData (tz_data).tz_data;									-- load the data table

	if args[1] then
		args[1] = args[1]:lower();												-- make lower case because tz table member indexes are lower case
	else
		args[1] = 'utc';														-- default to utc
	end
	
	if mw.ustring.match (args[1], 'utc[%+%-±−]?%d%d:%d%d') then					-- if rendering time for a UTC offset timezone
		tz.abbr = args[1]:upper():gsub('%-', '−');								-- set the link label to upper case and replace hyphen with a minus character (U+2212)
		tz.article = tz.abbr;													-- article title same as abbreviation
		tz.utc_offset = mw.ustring.match (args[1], 'utc([%+%-±−]?%d%d:%d%d)'):gsub('−', '%-');	-- extract the offset value; replace minus character with hyphen
		tz.df = 'iso';
		args[1] = 'utc_offsets';												-- spoof to show that we recognize this timezone
	else
		tz = tz_data[args[1]];													-- make a local copy of the timezone table from tz_data{}
	end

	if not is_set (tz_data[args[1]]) then
		return '<span style="font-size:100%" class="error">{{time}} – unknown timezone ([[Template:Time#Error messages|help]])</span>';
	end
	
	df = args.df or args[2] or tz.df or 'mdy';									-- template |df= overrides typical df from tz properties TODO: error check these values?
	if is_set (df) then
		df = df:lower();														-- lower case because we will compare to lower case values later
	end

	if is_set (args._TEST_TIME_) then											-- typically used to test the code at a specific utc time
		local test_time = get_test_time (args._TEST_TIME_);
		if not test_time then
			return '<span style="font-size:100%" class="error">{{time}} – malformed or incomplete _TEST_TIME_ ([[Template:Time#Error messages|help]])</span>';
		end

--		utc_timestamp = os.time(get_test_time (args._TEST_TIME_));
		utc_timestamp = os.time(test_time);
	else
		utc_timestamp = os.time ();												-- get current server time (UTC)
	end
	utc_offset = get_utc_offset ();												-- utc offset for specified timezone in seconds
	timestamp = utc_timestamp + utc_offset;										-- make local time timestamp

	if 'no' == args.dst then													-- for timezones that DO observe dst but for this location ...
		tz_abbr = tz.abbr;														-- ... dst is not observed (|dst=no) show time as standard time
	else
		if is_set (tz.dst_begins) and is_set (tz.dst_ends) and is_set (tz.dst_time) then	-- make sure we have all of the parts
			dst_begin_ts, dst_end_ts, invert = make_dst_timestamps (timestamp);	-- get begin and end dst timestamps and invert flag

			if nil == dst_begin_ts or nil == dst_end_ts then
				return '<span style="font-size:100%" class="error">{{time}} – error calculating dst timestamps ([[Template:Time#Error messages|help]])</span>';
			end
	
			if invert then														-- southern hemisphere; use beginning and ending of standard time in the comparison
				if utc_timestamp >= dst_end_ts and utc_timestamp < dst_begin_ts then	-- is current date time standard time?
					tz_abbr = tz.abbr;											-- standard time abbreviation
				else		
					timestamp = timestamp + 3600;								-- add an hour
					tz_abbr = tz.dst_abbr;										-- dst abbreviation
				end
			else																-- northern hemisphere
				if utc_timestamp >= dst_begin_ts and utc_timestamp < dst_end_ts then	-- all timestamps are UTC
					timestamp = timestamp + 3600;								-- add an hour 
					tz_abbr = tz.dst_abbr;
				else
					tz_abbr = tz.abbr;
				end
			end
		elseif is_set (tz.dst_begins) or is_set (tz.dst_ends) or is_set (tz.dst_time) then	-- if some but not all not all parts then emit error message
			return table.concat ({'<span style="font-size:100%" class="error">{{time}} – incomplete definition for ', args[1]:upper(), ' ([[Template:Time#Error messages|help]])</span>'});
		else
			tz_abbr = tz.abbr;													-- dst not observed for this timezone
		end
	end
	
	if dateonly then
		if 'iso' == df then														-- |df=iso
			df = 'iso_date';
		elseif df:find ('^dmy') or 'y' == df then								-- |df=dmy, |df=dmy12, |df=dmy24, |df=y
			df = 'dmy_date';
		else
			df = 'mdy_date';													-- default
		end

	elseif timeonly or df:match ('^%d+$') then									-- time only of |df= is just digits
		df = table.concat ({'t', df:match ('%l*(12)') or '24'});				-- |df=12, |df=24, |df=dmy12, |df=dmy24, |df=mdy12, |df=mdy24; default to t24
		
	elseif 'y' == df or 'dmy24' == df then
		df = 'dmy';

	elseif 'mdy24' == df then
		df = 'mdy';
	end
	
	local format = {
		t12 = '%l:%M %p',														-- time only
		t24 = '%R',
		
		iso_date ='%F',															-- date only
		dmy_date = '%e %B %Y',
		mdy_date = '%B %e, %Y',
		
		dmy12 = '%l:%M %p, %e %B %Y',											-- 12hr time and date
		mdy12 = '%l:%M %p, %B %e, %Y',
		dmy = '%R, %e %B %Y',													-- 24hr time and date
		mdy = '%R, %B %e, %Y',
		iso = '%FT%R'
		}
	
	if format[df] then
		time_string = mw.text.trim (os.date (format[df], timestamp));
	else
		return table.concat ({'<span style="font-size:100%" class="error">{{time}} – invalid date format ', df, ' ([[Template:Time#Error messages|help]])</span>'});
	end
	
	if not is_set (tz.article) then												-- if some but not all not all parts then emit error message
		return table.concat ({'<span style="font-size:100%" class="error">{{time}} – incomplete definition for ', args[1]:upper(), ' ([[Template:Time#Error messages|help]])</span>'});
	end
	
	local refresh_link = (hide_refresh and '') or
		table.concat ({
			' <span class="plainlinks" style="font-size:85%;">[[',				-- open span
			mw.title.getCurrentTitle():fullUrl({action = 'purge'}),				-- add the a refresh link url
			' refresh]]</span>',												-- close the span
			});

	local tz_tag = (hide_tz and '') or
		((unlink_tz and table.concat ({' ', tz_abbr})) or						-- unlinked
			table.concat ({' [[', tz.article, '|', tz_abbr, ']]'}));			-- linked
	
	return table.concat ({time_string, tz_tag, refresh_link});

end


--[[--------------------------< E X P O R T E D   F U N C T I O N S >------------------------------------------
]]

return {time = time}