It is currently September 15th, 2019, 11:03 pm

Parsing a date and time string in Lua

Tips and Tricks from the Rainmeter Community
User avatar
jsmorley
Developer
Posts: 19440
Joined: April 19th, 2009, 11:02 pm
Location: Fort Hunt, Virginia, USA

Parsing a date and time string in Lua

jsmorley » June 19th, 2016, 5:03 pm

Weeks, Days, Hours, Minutes and Seconds

One of the things that often comes up is how to figure out how much elapsed (or remaining) time there is from now to a date and time string you get from somewhere, perhaps an RSS feed.

So you might get a string like 2016-06-18T16:04:17Z, and want to know how many weeks, days, hours, minutes and seconds old (or in the future) that date and time is.

This guide assumes that the date and time string will be returned in an ISO-8601 standard date and time format. This are generally going to be one of two things:

2016-06-18T16:04:17Z

The date as yyyy-mm-dd followed by a "T" for "time" and then hh:mm:ss followed by a "Z" to indicate "Zulu" or UTC time.

or:

2016-06-18T12:04:17-04:00

The date as yyyy-mm-dd followed by a "T" for "time" and then hh:mm:ss followed by a positive or negative hh:mm offset from UTC that the string is returning. This will be when the resource you are getting the date from returns some local server time, perhaps on a site like our forums where you set your personal time zone information and the RSS feed returns things in your local time.

Both of these are reasonably common, although you will find that the vast majority of sites will return date strings in UTC.

First let's have a completed .rmskin that you can use to follow along, and can edit as you need for your own uses:
DateTimeLua_1.0.rmskin
That might display something like:
1.jpg
The skin:

Code: Select all

[Rainmeter]
Update=1000
DynamicWindowSize=1
AccurateText=1

[Metadata]
Name=DateTimeLua
Author=JSMorley
Version=Jun 18, 2016
License=Creative Commons Attribution-Non-Commercial-Share Alike 3.0
Information=Demonstrates parsing ISO standard date / time strings into elapsed or remaining Weeks/Days/Hours/Minutes/Seconds.

[TimeMeasure]
Measure=String
;String=2016-06-18T12:04:17-04:00
String=2016-06-18T16:04:17Z

[MeasureScript]
Measure=Script
ScriptFile=DateTimeLua.lua

[MeterNowLocal]
Meter=String
FontSize=12
FontColor=255,255,255,255
SolidColor=0,0,0,1
AntiAlias=1

[MeterNowUTC]
Meter=String
Y=5R
FontSize=12
FontColor=255,255,255,255
SolidColor=0,0,0,1
AntiAlias=1

[MeterDate1Original]
Meter=String
Y=10R
FontSize=12
FontColor=255,255,255,255
SolidColor=0,0,0,1
AntiAlias=1

[MeterDate1Formatted]
Meter=String
Y=5R
FontSize=12
FontColor=255,255,255,255
SolidColor=0,0,0,1
AntiAlias=1

[MeterDiffFormatted]
Meter=String
Y=10R
FontSize=12
FontColor=255,255,255,255
SolidColor=0,0,0,1
AntiAlias=1
This is pretty simple and straightforward. I just use a String measure to simulate getting a date and time string from some remote resource, which you will likely do with WebParser. For this exercise, just pretend that [TimeMeasure] is a WebParser "child" measure, returning a formatted date and time string in StringIndex 1.

I set up some meters to hold our information, but I don't have any MeasureName or Text values for those, we are going to let the Lua set them in a bit...

The Lua script

Code: Select all

function Initialize()

	standardFormat = '%A, %B %d %Y at %H:%M:%S'
	timeMeasure = SKIN:GetMeasure('TimeMeasure')
	
end

function Update()

	local itemDate = timeMeasure:GetStringValue()
	if itemDate == '' then return -1 end
	
	local timeLocalNow = os.time(os.date('*t'))
	local timeUTCNow = os.time(os.date('!*t'))
	if os.date(isdst) then
		timeUTCAdjusted = timeUTCNow - 3600
	end
		
	local itemTimeStamp = TimeStamp(itemDate)
	
	local diffTotal = timeUTCNow - itemTimeStamp
	
	SKIN:Bang('!SetOption', 'MeterNowLocal', 'Text', 'Now Local:	'..os.date(standardFormat, timeLocalNow))
	SKIN:Bang('!SetOption', 'MeterNowUTC', 'Text', 'Now UTC:	'..os.date(standardFormat, timeUTCAdjusted))
	SKIN:Bang('!SetOption', 'MeterDate1Original', 'Text', 'Input String:	'..itemDate)
	SKIN:Bang('!SetOption', 'MeterDate1Formatted', 'Text', 'Formatted:	'..os.date(standardFormat, itemTimeStamp))	
	
	if diffTotal >= 0 then
		textPrefix = 'Elapsed:'
	else
		textPrefix = 'Remaining:'
	end
		
	diffWeeks, diffDays, diffHours, diffMinutes, diffSeconds = FormatSeconds(math.abs(diffTotal))
	
	if diffWeeks == 1 then
		outputWeeks = diffWeeks..' Week'
	else
		outputWeeks = diffWeeks..' Weeks'
	end
	
	if diffDays == 1 then
		outputDays = diffDays..' Day'
	else
		outputDays = diffDays..' Days'
	end
	
	if diffHours == 1 then
		outputHours = diffHours..' Hour'
	else
		outputHours = diffHours..' Hours'
	end
	
	if diffMinutes == 1 then
		outputMinutes = diffMinutes..' Minute'
	else
		outputMinutes = diffMinutes..' Minutes'
	end	
	
	if diffSeconds == 1 then
		outputSeconds = diffSeconds..' Second'
	else
		outputSeconds = diffSeconds..' Seconds'
	end		
	
	if diffWeeks > 0 then
		outputString = textPrefix..'  	'..outputWeeks..' '..outputDays..' '..outputHours..' '..outputMinutes..' '..outputSeconds
	elseif diffDays > 0 then
		outputString = textPrefix..'  	'..outputDays..' '..outputHours..' '..outputMinutes..' '..outputSeconds
	elseif diffHours > 0 then
		outputString = textPrefix..'  	'..outputHours..' '..outputMinutes..' '..outputSeconds
	elseif diffMinutes > 0 then
		outputString = textPrefix..'  	'..outputMinutes..' '..outputSeconds
	else
		outputString = textPrefix..'  	'..outputSeconds
	end
	
	SKIN:Bang('!SetOption', 'MeterDiffFormatted', 'Text', outputString)
	
	return diffTotal
	
end

function TimeStamp(dateStringArg)
	
	local inYear, inMonth, inDay, inHour, inMinute, inSecond, inZone =      
  string.match(dateStringArg, '^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)(.-)$')

	local zHours, zMinutes = string.match(inZone, '^(.-):(%d%d)$')
		
	local returnTime = os.time({year=inYear, month=inMonth, day=inDay, hour=inHour, min=inMinute, sec=inSecond, isdst=false})
	
	if zHours then
		returnTime = returnTime - ((tonumber(zHours)*3600) + (tonumber(zMinutes)*60))
	end
	
	return returnTime
	
end

function FormatSeconds(secondsArg)

	local weeks = math.floor(secondsArg / 604800)
	local remainder = secondsArg % 604800
	local days = math.floor(remainder / 86400)
	local remainder = remainder % 86400
	local hours = math.floor(remainder / 3600)
	local remainder = remainder % 3600
	local minutes = math.floor(remainder / 60)
	local seconds = remainder % 60
	
	return weeks, days, hours, minutes, seconds
	
end
Using the os.date and os.time Lua functions

There are two main time and date functions in Lua, which compliment each other. These are os.time() and os.date(), which are described in some detail here:

https://www.lua.org/pil/22.1.html

os.time returns a timestamp number. By default, with no arguments, it returns the current system date and time as a number of seconds from a standard epoch of 00:00:00 UTC on 1 January 1970.

os.time will also accept a table as an argument, with the following possible fields:

year
month
day
hour
min
sec
isdst

Not all fields are required. So for example:

newYear = os.time{year=2017, month=1, day=1}

Would return a number that is the number of seconds from January 1, 1970 at midnight to January 1, 2017 at midnight. This is how you ask Lua to give you a timestamp number for some specific date and time.

os.date is really just the reverse of os.time. It takes arguments of a format string and a timestamp number, and can return one of two things:

If you specify a format string of '*t' (splat-t) as in nowTable = os.date('*t') it will return a table much the same as the one the os.time function supports above. By default the second argument, the timestamp number, will be the current system date and time.

Note that if you specify '!*t' (bang-splat-t) you will get the current standard time in UTC. If you get the UTC time, daylight savings time is not considered. More on that in a bit.

If you specify any other format string, it will produce a formatted string from either the current time, or a timestamp you give it as the second argument. It will use much the same (although not exactly the same) format codes that the Time measure in Rainmeter uses. See the https://www.lua.org/pil/22.1.html link above for the supported codes.

So os.time and os.date really do compliment each other. In many cases, you will use a table returned by os.date as input to os.time, and in others use a timestamp returned by os.time as input to os.date. Hopefully this will be come clear as we get into our Lua code.

What our Lua code does at a high level

1) Get the current system date and time as a formatted string.
2) Get the current UTC date and time as a formatted string.
3) Get an ISO-8601 formatted date and time string from the skin.
4) Convert the components of that formatted string into table fields that we will use to get a timestamp from os.time.
5) Compare that timestamp to the current time, to determine how many seconds difference there is.
6) Convert that number of seconds difference into numbers of weeks/days/hours/minutes/seconds.
7) Send all this information back to the skin for display in meters. Again, the goal is something like this:
1.jpg
Walking through the Lua code

The Initialize() function will do two things. It will set up a variable to hold a standard format string that we will use with os.date to pretty up the current date and time, and it will get a "handle" to the measure in our skin that will be giving us the ISO-8601 date and time string we want to parse.

The Update() function will do the following:

Get the current value of the measure from the skin
local itemDate = timeMeasure:GetStringValue()

Get the current local system time
local timeLocalNow = os.time(os.date('*t'))

Get the current UTC time
local timeUTCNow = os.time(os.date('!*t'))

The next bit takes just a tad of explaining. Remember that when we get UTC time with '!*t' Daylight Saving Time is not considered. However, if our local time zone is in fact currently in Daylight Saving Time, we do want to consider that when we format and display the current UTC time.

Code: Select all

	if os.date(isdst) then
		timeUTCAdjusted = timeUTCNow - 3600
	end
What we are doing is checking the field "isdst" (is it currently Daylight Saving Time?) in our current local time, to see if it is "true" or "false". If it is "true", we want to subtract 3600 seconds (one hour) from the UTC time returned, so it reflects that we are currently in Daylight Saving Time. Note that this is only important if we want to display the correct current UTC time as a formatted string, and really will have nothing to do with comparing our ISO-8601 string from the measure with UTC to get "elapsed time".

Now we are passing our ISO-8601 date and time string from the skin to a function called TimeStamp().

local itemTimeStamp = TimeStamp(itemDate)

What we want back from TimeStamp(itemDate) is a timestamp, the number of seconds from from January 1, 1970 at midnight to the date and time we pass it as a formatted string.

We are going to parse that string, and then use os.time to get that timestamp number for us. Let's walk through the function:

Code: Select all

function TimeStamp(dateStringArg)
	
	local inYear, inMonth, inDay, inHour, inMinute, inSecond, inZone =      
  string.match(dateStringArg, '^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)(.-)$')

	local zHours, zMinutes = string.match(inZone, '^(.-):(%d%d)$')
		
	local returnTime = os.time({year=inYear, month=inMonth, day=inDay, hour=inHour, min=inMinute, sec=inSecond, isdst=false})
	
	if zHours then
		returnTime = returnTime - ((tonumber(zHours)*3600) + (tonumber(zMinutes)*60))
	end
	
	return returnTime
	
end
What we do is use string.match(), basically regular expression, to "parse" the information from the string into values for the inYear, inMonth, inDay, inHour, InMinute, inSecond and inZone.

Now, remember that our ISO-8601 formatted string can take one of two forms:
2016-06-18T16:04:17Z
Where the "Z" just means "Zulu" or UTC time
2016-06-18T12:04:17-04:00
Where the "-04:00" is an offset in hours and minutes from UTC that is being provided.

So we are parsing inZone for that "-04:00" mask, and if zHours exists, if the match succeeded, we know we need to account for that time zone offset. We want to "end up" with UTC time, never any local time.

Then we return that number created by os.time (and adjusted for time zone if needed) back to the main part of the Lua.

So now we have a timestamp that reflects the UTC time in our ISO-8601 string from the skin, and we have a timestamp that reflects the current time in UTC, the difference in seconds is simple subtraction.

local diffTotal = timeUTCNow - itemTimeStamp

Note that if the date and time string from the skin is in the "past" the value of diffTotal will be positive, or elapsed time from then. If it is in the future, diffTotal will be negative, or remaining time from now.

We then use SKIN:Bang() to set the meter values for the information we already have. All that's left is to turn diffTotal (the seconds of difference) into the number of Weeks/Days/Hours/Minutes/Seconds.

That is done with a call to the function FormatSeconds(). This is a pretty straightforward function that uses modulo math to set and return our desired values. It's pretty standard, and will always work, no matter how you get some number of seconds to pass it. The function can always be copied from https://docs.rainmeter.net/snippets/format-time/ when you need it.

Code: Select all

function FormatSeconds(secondsArg)

	local weeks = math.floor(secondsArg / 604800)
	local remainder = secondsArg % 604800
	local days = math.floor(remainder / 86400)
	local remainder = remainder % 86400
	local hours = math.floor(remainder / 3600)
	local remainder = remainder % 3600
	local minutes = math.floor(remainder / 60)
	local seconds = remainder % 60
	
	return weeks, days, hours, minutes, seconds
	
end
Note that we use math.abs() when we send the seconds to FormatSeconds() as it doesn't care about, and in fact will hate, any distinction between positive and negative seconds. It's just seconds... math.abs() will use the absolute, or positive, value of any number.

Also note that it is not possible to accurately turn a number of seconds into anything higher than "Weeks". Months, Quarters, Years, Decades, Centuries etc. don't have a fixed number of seconds.

So we have all the values we need. The rest of the Lua is just to pretty things up, use the singular for the components of time if the value is "1", and use SKIN:Bang() to set the meter that will display the Weeks/Days/Hours/Minutes/Seconds of elapsed or remaining time.

Hope this gives you some idea of how you might use Lua with date and time strings. It's a powerful part of the Lua language, and there is a lot you can do with it other than just this example. Feel free to ask any questions you might have.
You do not have the required permissions to view the files attached to this post.
User avatar
jsmorley
Developer
Posts: 19440
Joined: April 19th, 2009, 11:02 pm
Location: Fort Hunt, Virginia, USA

Re: Parsing a date and time string in Lua

jsmorley » June 19th, 2016, 5:52 pm

Don't get too fixated on the ISO-8601 date and time format. While that is going to be the most common from RSS feeds and such, there is no reason all of this can't work with any formatted date and time string, as long as you can use string.match() to wrangle the string into values for some or all of the fields for:

os.time({year=inYear, month=inMonth, day=inDay, hour=inHour, min=inMinute, sec=inSecond, isdst=false})

For instance:

Skin:

Code: Select all

[TimeMeasure]
Measure=String
String=Sunday, June 19 2016 at 1:55 PM
Lua:

Code: Select all

function Initialize()

	standardFormat = '%A, %B %d %Y at %H:%M:%S'
	timeMeasure = SKIN:GetMeasure('TimeMeasure')
	
end

function Update()

	local itemDate = timeMeasure:GetStringValue()
	if itemDate == '' then return -1 end
	
	local timeLocalNow = os.time(os.date('*t'))
	local timeUTCNow = os.time(os.date('!*t'))
		
	local itemTimeStamp = TimeStamp(itemDate)

	if os.date(isdst) then
		timeUTCAdjusted = timeUTCNow - 3600
		timeLocalNowAdjusted = timeLocalNow + 3600
		itemTimeStampAdjusted = itemTimeStamp - 3600
	end
	
	SKIN:Bang('!SetOption', 'MeterNowLocal', 'Text', 'Now Local:	'..os.date(standardFormat, timeLocalNow))
	SKIN:Bang('!SetOption', 'MeterNowUTC', 'Text', 'Now UTC:	'..os.date(standardFormat, timeUTCAdjusted))
	SKIN:Bang('!SetOption', 'MeterDate1Original', 'Text', 'Input String:	'..itemDate)
	SKIN:Bang('!SetOption', 'MeterDate1Formatted', 'Text', 'Formatted:	'..os.date(standardFormat, itemTimeStampAdjusted))	
	
	local diffTotal = timeLocalNowAdjusted - itemTimeStamp
	
	if diffTotal >= 0 then
		textPrefix = 'Elapsed:'
	else
		textPrefix = 'Remaining:'
	end
		
	diffWeeks, diffDays, diffHours, diffMinutes, diffSeconds = FormatSeconds(math.abs(diffTotal))
	
	if diffWeeks == 1 then
		outputWeeks = diffWeeks..' Week'
	else
		outputWeeks = diffWeeks..' Weeks'
	end
	
	if diffDays == 1 then
		outputDays = diffDays..' Day'
	else
		outputDays = diffDays..' Days'
	end
	
	if diffHours == 1 then
		outputHours = diffHours..' Hour'
	else
		outputHours = diffHours..' Hours'
	end
	
	if diffMinutes == 1 then
		outputMinutes = diffMinutes..' Minute'
	else
		outputMinutes = diffMinutes..' Minutes'
	end	
	
	if diffSeconds == 1 then
		outputSeconds = diffSeconds..' Second'
	else
		outputSeconds = diffSeconds..' Seconds'
	end		
	
	if diffWeeks > 0 then
		outputString = textPrefix..'  	'..outputWeeks..' '..outputDays..' '..outputHours..' '..outputMinutes..' '..outputSeconds
	elseif diffDays > 0 then
		outputString = textPrefix..'  	'..outputDays..' '..outputHours..' '..outputMinutes..' '..outputSeconds
	elseif diffHours > 0 then
		outputString = textPrefix..'  	'..outputHours..' '..outputMinutes..' '..outputSeconds
	elseif diffMinutes > 0 then
		outputString = textPrefix..'  	'..outputMinutes..' '..outputSeconds
	else
		outputString = textPrefix..'  	'..outputSeconds
	end
	
	SKIN:Bang('!SetOption', 'MeterDiffFormatted', 'Text', outputString)
	
	return diffTotal
	
end

function TimeStamp(dateStringArg)
	
	local monthNum={January=1;February=2;March=3;April=4;May=5;June=6;July=7;August=8;September=9;October=10;November=11;December=12;}

	local inMonth, inDay, inYear, inHour, inMinute, inAMPM =
	string.match(dateStringArg, '^.-, (.-) (%d+) (%d%d%d%d) at (%d+):(%d%d) (.)M$')
	
	if inAMPM == 'P' then
		inHour = inHour + 12
	end
	
	local returnTime = os.time({year=inYear, month=monthNum[inMonth], day=inDay, hour=inHour, min=inMinute, isdst=false})
	
	return returnTime
	
end

function FormatSeconds(secondsArg)

	local weeks = math.floor(secondsArg / 604800)
	local remainder = secondsArg % 604800
	local days = math.floor(remainder / 86400)
	local remainder = remainder % 86400
	local hours = math.floor(remainder / 3600)
	local remainder = remainder % 3600
	local minutes = math.floor(remainder / 60)
	local seconds = remainder % 60
	
	return weeks, days, hours, minutes, seconds
	
end
1.jpg
I'll leave it to you to figure out what I did there, but with some thought, just about any date / time format should be able to be parsed and used.
You do not have the required permissions to view the files attached to this post.