587 lines
14 KiB
Lua
587 lines
14 KiB
Lua
local LocalizationService = game:GetService("LocalizationService")
|
|
local CorePackages = game:GetService("CorePackages")
|
|
local GetFFlagUseDateTimeType = require(CorePackages.AppTempCommon.LuaApp.Flags.GetFFlagUseDateTimeType)
|
|
|
|
--[[
|
|
This is a Lua implementation of the DateTime API proposal. It'll eventually
|
|
be implemented in C++ and merged into the rest of the codebase if this model
|
|
of working with dates ends up being useful.
|
|
]]
|
|
|
|
local TimeZone = require(script.Parent.TimeZone)
|
|
local TimeUnit = require(script.Parent.TimeUnit)
|
|
|
|
local LuaDateTime = {}
|
|
|
|
local monthShortNames = {
|
|
"Jan", "Feb", "Mar", "Apr",
|
|
"May", "Jun", "Jul", "Aug",
|
|
"Sep", "Oct", "Nov", "Dec"
|
|
}
|
|
|
|
local monthLongNames = {
|
|
"January", "February", "March", "April",
|
|
"May", "June", "July", "August",
|
|
"September", "October", "November", "December"
|
|
}
|
|
|
|
local dayShortNames = {
|
|
"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"
|
|
}
|
|
|
|
local dayLongNames = {
|
|
"Sunday", "Monday", "Tuesday", "Wednesday",
|
|
"Thursday", "Friday", "Saturday"
|
|
}
|
|
|
|
--[[
|
|
We structure tokens like this to preserve order, since Lua associative
|
|
arrays have no inherent order.
|
|
]]
|
|
local tokens = {
|
|
{"YYYY", function(values)
|
|
return tostring(values.Year)
|
|
end},
|
|
{"MMMM", function(values)
|
|
return monthLongNames[values.Month]
|
|
end},
|
|
{"MMM", function(values)
|
|
return monthShortNames[values.Month]
|
|
end},
|
|
{"MM", function(values)
|
|
return ("%02d"):format(values.Month)
|
|
end},
|
|
{"M", function(values)
|
|
return tostring(values.Month)
|
|
end},
|
|
{"DDDD", function(values)
|
|
return dayLongNames[values.WeekDay]
|
|
end},
|
|
{"DDD", function(values)
|
|
return dayShortNames[values.WeekDay]
|
|
end},
|
|
{"DD", function(values)
|
|
return ("%02d"):format(values.Day)
|
|
end},
|
|
{"D", function(values)
|
|
return tostring(values.Day)
|
|
end},
|
|
{"HH", function(values)
|
|
local hour = values.Hour
|
|
|
|
return ("%02d"):format(hour)
|
|
end},
|
|
{"H", function(values)
|
|
local hour = values.Hour
|
|
|
|
return tostring(hour)
|
|
end},
|
|
{"hh", function(values)
|
|
local hour = values.Hour % 12
|
|
if hour == 0 then
|
|
hour = 12
|
|
end
|
|
|
|
return ("%02d"):format(hour)
|
|
end},
|
|
{"h", function(values)
|
|
local hour = values.Hour % 12
|
|
if hour == 0 then
|
|
hour = 12
|
|
end
|
|
|
|
return tostring(hour)
|
|
end},
|
|
{"mm", function(values)
|
|
return ("%02d"):format(values.Minute)
|
|
end},
|
|
{"m", function(values)
|
|
return tostring(values.Minute)
|
|
end},
|
|
{"ss", function(values)
|
|
return ("%02d"):format(values.Seconds)
|
|
end},
|
|
{"s", function(values)
|
|
return tostring(values.Seconds)
|
|
end},
|
|
{"A", function(values)
|
|
return values.Hour >= 12 and "PM" or "AM"
|
|
end},
|
|
{"a", function(values)
|
|
return values.Hour >= 12 and "pm" or "am"
|
|
end}
|
|
}
|
|
|
|
local tokenKeys = {}
|
|
for _, pair in ipairs(tokens) do
|
|
table.insert(tokenKeys, pair[1])
|
|
end
|
|
|
|
local tokenMap = {}
|
|
for _, pair in ipairs(tokens) do
|
|
tokenMap[pair[1]] = pair[2]
|
|
end
|
|
|
|
--[[
|
|
What's the next token in this source?
|
|
]]
|
|
local function getToken(source, i)
|
|
local char = source:sub(i, i)
|
|
|
|
for _, token in ipairs(tokenKeys) do
|
|
-- Only keep checking if the first character matches the token
|
|
if token:sub(1, 1) == char then
|
|
local match = source:sub(i, i + token:len() - 1)
|
|
|
|
if match == token then
|
|
return token
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
--[[
|
|
An estimate of the current time zone's offset from UTC in seconds.
|
|
|
|
This might fail for weird timezones (UTC +/- 14), but we can fix that by
|
|
picking a reference time that's further away from the Unix epoch.
|
|
]]
|
|
local function getTimeZoneOffset()
|
|
local actualEpoch = 86400 + 43200
|
|
local epoch = os.time({year = 1970, month = 1, day = 2, isdst = -1})
|
|
if epoch then
|
|
return actualEpoch - epoch
|
|
else
|
|
return 0
|
|
end
|
|
end
|
|
|
|
-- Remove all above functions and tables when clean up GetFFlagUseDateTimeType()
|
|
|
|
--[[
|
|
Create a DateTime with the given values in UTC.
|
|
|
|
All values are optional!
|
|
]]
|
|
function LuaDateTime.new(year, month, day, hour, minute, seconds, milliseconds)
|
|
if GetFFlagUseDateTimeType() then
|
|
local self = {}
|
|
self.dateTime = DateTime.fromUniversalTime(
|
|
year or 1970,
|
|
month or 1,
|
|
day or 1,
|
|
hour or 0,
|
|
minute or 0,
|
|
seconds or 0,
|
|
milliseconds or 0)
|
|
setmetatable(self, LuaDateTime)
|
|
return self
|
|
end
|
|
|
|
local tzOffset = getTimeZoneOffset()
|
|
local timestamp = os.time({
|
|
year = year or 1970,
|
|
month = month or 1,
|
|
day = day or 1,
|
|
hour = hour or 0,
|
|
min = minute or 0,
|
|
sec = seconds or 0,
|
|
isdst = -1
|
|
})
|
|
if timestamp == nil then
|
|
timestamp = 0
|
|
end
|
|
|
|
if seconds then
|
|
local subseconds = seconds - math.floor(seconds)
|
|
timestamp = timestamp + subseconds
|
|
end
|
|
|
|
return LuaDateTime.fromUnixTimestamp(timestamp + tzOffset)
|
|
end
|
|
|
|
--[[
|
|
Create a DateTime representing now.
|
|
]]
|
|
function LuaDateTime.now()
|
|
if GetFFlagUseDateTimeType() then
|
|
local self = {}
|
|
self.dateTime = DateTime.now()
|
|
setmetatable(self, LuaDateTime)
|
|
return self
|
|
end
|
|
|
|
return LuaDateTime.fromUnixTimestamp(os.time())
|
|
end
|
|
|
|
--[[
|
|
Create a Datetime from the given Unix timestamp.
|
|
|
|
Limited to the range [0, 2^32) when GetFFlagUseDateTimeType() is off, which lets us represent
|
|
dates out to about 2038.
|
|
|
|
When GetFFlagUseDateTimeType() is on, year range is 1400-9999, and timestamp range is between
|
|
first second of year 1400 to the last second of year 9999.
|
|
]]
|
|
function LuaDateTime.fromUnixTimestamp(timestamp)
|
|
assert(type(timestamp) == "number", "Invalid argument #1 to fromUnixTimestamp, expected number.")
|
|
|
|
if GetFFlagUseDateTimeType() then
|
|
local self = {}
|
|
self.dateTime = DateTime.fromUnixTimestampMillis(timestamp*1000)
|
|
setmetatable(self, LuaDateTime)
|
|
return self
|
|
end
|
|
|
|
local self = {}
|
|
|
|
self.value = timestamp
|
|
|
|
setmetatable(self, LuaDateTime)
|
|
|
|
return self
|
|
end
|
|
|
|
--[[
|
|
Attempt to create a DateTime from an ISO 8601 date-time string.
|
|
|
|
Will return nil on failure and output a warning to a console denoting what
|
|
went wrong. This can probably turned into a second return value if we need
|
|
to handle that data programmatically.
|
|
]]
|
|
function LuaDateTime.fromIsoDate(isoDate)
|
|
assert(type(isoDate) == "string", "Invalid argument #1 to DateTime.fromIsoDate, expected string.")
|
|
|
|
if GetFFlagUseDateTimeType() then
|
|
local self = {}
|
|
self.dateTime = DateTime.fromIsoDate(isoDate)
|
|
setmetatable(self, LuaDateTime)
|
|
return self
|
|
end
|
|
|
|
local datePattern = "^(%d+)%-(%d+)%-(%d+)" -- 0000-00-00
|
|
local timePattern = "T(%d+):(%d+):(%d+%.?%d*)" -- T00:00:00
|
|
local utcPattern = "Z$"
|
|
local timeZonePattern = "([+-]%d+):(%d+)$" -- either Z or +/- followed by "00:00"
|
|
|
|
local timezone = 0
|
|
local values = {1970, 1, 1, 0, 0, 0}
|
|
local year, month, day = isoDate:match(datePattern)
|
|
|
|
if not year then
|
|
warn(("Invalid ISO 8601 date: %q"):format(isoDate))
|
|
return nil
|
|
end
|
|
|
|
values[1] = tonumber(year)
|
|
values[2] = tonumber(month)
|
|
values[3] = tonumber(day)
|
|
|
|
local hour, minute, seconds = isoDate:match(timePattern)
|
|
|
|
if hour then
|
|
values[4] = tonumber(hour)
|
|
values[5] = tonumber(minute)
|
|
values[6] = tonumber(seconds)
|
|
|
|
local isUtc = isoDate:match(utcPattern)
|
|
|
|
if not isUtc then
|
|
local offsetHours, offsetMinutes = isoDate:match(timeZonePattern)
|
|
|
|
if not offsetHours then
|
|
local offsetTotal = getTimeZoneOffset()
|
|
offsetHours = offsetTotal / 3600
|
|
offsetMinutes = 0
|
|
|
|
warn(("Invalid time zone in ISO 8601 date: %q -- falling back to local time"):format(isoDate))
|
|
end
|
|
|
|
timezone = 3600 * tonumber(offsetHours) + 60 * tonumber(offsetMinutes)
|
|
end
|
|
end
|
|
|
|
local date = LuaDateTime.new(unpack(values))
|
|
date.value = date.value - timezone
|
|
|
|
return date
|
|
end
|
|
|
|
--[[
|
|
Format our current date using a formatting string. Look at the DateTime
|
|
proposal to see information about the different formatting tokens.
|
|
Generally, they try to resemble LDML and/or Moment.js-style formatting.
|
|
|
|
The time zone parameter is optional and defaults to the current time zone,
|
|
TimeZone.Current.
|
|
]]
|
|
function LuaDateTime:Format(formatString, tz, localeId)
|
|
assert(type(formatString) == "string", "Invalid argument #1 to Format, expected string.")
|
|
|
|
if GetFFlagUseDateTimeType() then
|
|
tz = tz or TimeZone.Current
|
|
localeId = localeId or LocalizationService.RobloxLocaleId
|
|
|
|
if tz == TimeZone.UTC then
|
|
return self.dateTime:FormatUniversalTime(formatString, localeId)
|
|
elseif tz == TimeZone.Current then
|
|
return self.dateTime:FormatLocalTime(formatString, localeId)
|
|
else
|
|
error(("Invalid TimeZone \"%s\""):format(tostring(tz)), 2)
|
|
end
|
|
end
|
|
|
|
tz = tz or TimeZone.Current
|
|
|
|
local values = self:GetValues(tz)
|
|
|
|
local buffer = {}
|
|
|
|
local i = 1
|
|
while i <= formatString:len() do
|
|
local char = formatString:sub(i, i)
|
|
local token = getToken(formatString, i)
|
|
|
|
if token then
|
|
table.insert(buffer, tokenMap[token](values))
|
|
i = i + token:len()
|
|
elseif char == "[" then
|
|
-- Crawl forward until the next ] and interpret that text literally
|
|
local j = i
|
|
while j <= formatString:len() do
|
|
j = j + 1
|
|
|
|
if formatString:sub(j, j) == "]" then
|
|
break
|
|
end
|
|
end
|
|
|
|
table.insert(buffer, formatString:sub(i + 1, j - 1))
|
|
i = j + 1
|
|
else
|
|
table.insert(buffer, char)
|
|
i = i + 1
|
|
end
|
|
end
|
|
|
|
local result = table.concat(buffer)
|
|
|
|
return result
|
|
end
|
|
|
|
--[[
|
|
Get a table of values representing the date-time in the given timezone.
|
|
|
|
The time zone parameter is optional and defaults to the current zime zone,
|
|
TimeZone.Current.
|
|
|
|
When GetFFlagUseDateTimeType() is true, table would include
|
|
{Year, Month, Day, Hour, Minute, Second, Millisecond}
|
|
]]
|
|
function LuaDateTime:GetValues(tz)
|
|
if GetFFlagUseDateTimeType() then
|
|
tz = tz or TimeZone.Current
|
|
|
|
if tz == TimeZone.UTC then
|
|
return self.dateTime:ToUniversalTime()
|
|
elseif tz == TimeZone.Current then
|
|
return self.dateTime:ToLocalTime()
|
|
else
|
|
error(("Invalid TimeZone \"%s\""):format(tostring(tz)), 2)
|
|
end
|
|
end
|
|
|
|
tz = tz or TimeZone.Current
|
|
|
|
local reference
|
|
|
|
if tz == TimeZone.Current then
|
|
reference = os.date("*t", self.value)
|
|
elseif tz == TimeZone.UTC then
|
|
reference = os.date("!*t", self.value)
|
|
end
|
|
|
|
if not reference then
|
|
error(("Invalid TimeZone \"%s\""):format(tostring(tz)), 2)
|
|
end
|
|
|
|
return {
|
|
Year = reference.year,
|
|
Month = reference.month,
|
|
Day = reference.day,
|
|
Hour = reference.hour,
|
|
Minute = reference.min,
|
|
Seconds = reference.sec,
|
|
WeekDay = reference.wday
|
|
}
|
|
end
|
|
|
|
--[[
|
|
Recover a Unix timestamp representing the DateTime's value.
|
|
]]
|
|
function LuaDateTime:GetUnixTimestamp()
|
|
if GetFFlagUseDateTimeType() then
|
|
if self.dateTime:ToUniversalTime().Millisecond > 0 then
|
|
return self.dateTime.UnixTimestamp + (self.dateTime.UnixTimestampMillis % 1000)/1000
|
|
else
|
|
return self.dateTime.UnixTimestamp
|
|
end
|
|
end
|
|
|
|
return self.value
|
|
end
|
|
|
|
--[[
|
|
Format the DateTime as an ISO 8601 date string with time attached.
|
|
|
|
Always formats the time as UTC. There generally aren't many reasons to
|
|
generate an ISO 8601 date in another time zone.
|
|
]]
|
|
function LuaDateTime:GetIsoDate()
|
|
if GetFFlagUseDateTimeType() then
|
|
return self.dateTime:ToIsoDate()
|
|
end
|
|
|
|
return self:Format("YYYY-MM-DD[T]HH:mm:ss[Z]", TimeZone.UTC)
|
|
end
|
|
|
|
-- Used by IsSame
|
|
-- Remove when clean up GetFFlagUseDateTimeType()
|
|
local descendingGranularityUnits = {
|
|
{
|
|
unit = TimeUnit.Years,
|
|
key = "Year"
|
|
},
|
|
{
|
|
unit = TimeUnit.Months,
|
|
key = "Month"
|
|
},
|
|
{
|
|
unit = TimeUnit.Days,
|
|
key = "Day"
|
|
},
|
|
{
|
|
unit = TimeUnit.Hours,
|
|
key = "Hour"
|
|
},
|
|
{
|
|
unit = TimeUnit.Minutes,
|
|
key = "Minute"
|
|
},
|
|
{
|
|
unit = TimeUnit.Seconds,
|
|
key = "Seconds"
|
|
}
|
|
}
|
|
|
|
--[[
|
|
Checks whether two DateTime values are the same, given a granularity and
|
|
timezone value.
|
|
|
|
Granularity defaults to seconds and time zone defaults to the current local
|
|
time zone.
|
|
|
|
Remove when clean up GetFFlagUseDateTimeType()
|
|
]]
|
|
function LuaDateTime:IsSame(other, granularity, timezone)
|
|
granularity = granularity or TimeUnit.Seconds
|
|
timezone = timezone or TimeZone.Current
|
|
|
|
local selfUnix = self:GetUnixTimestamp()
|
|
local otherUnix = other:GetUnixTimestamp()
|
|
|
|
if selfUnix == otherUnix then
|
|
return true
|
|
end
|
|
|
|
local selfValues = self:GetValues(timezone)
|
|
local otherValues = other:GetValues(timezone)
|
|
|
|
-- Week logic is special
|
|
if granularity == TimeUnit.Weeks then
|
|
local diff = math.abs(selfUnix - otherUnix)
|
|
local diffDays = diff / (60 * 60 * 24)
|
|
|
|
-- Two dates separated by 7 or more whole days are never in the same week
|
|
if diffDays >= 7 then
|
|
return false
|
|
end
|
|
|
|
-- Two dates separated by less than 7 days will be sorted monotonically
|
|
-- if they're in the same week
|
|
-- TODO: Use start-of-week value to shift WeekDay for locale
|
|
if selfUnix > otherUnix then
|
|
return selfValues.WeekDay >= otherValues.WeekDay
|
|
else
|
|
return selfValues.WeekDay <= otherValues.WeekDay
|
|
end
|
|
end
|
|
|
|
for _, unit in ipairs(descendingGranularityUnits) do
|
|
local selfValue = selfValues[unit.key]
|
|
local otherValue = otherValues[unit.key]
|
|
|
|
if selfValue ~= otherValue then
|
|
return false
|
|
end
|
|
|
|
if unit.unit == granularity then
|
|
break
|
|
end
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
--[[
|
|
Get a human-readable timestamp relative to the given epoch, which defaults
|
|
to now. The format of the time is contextual to how far away the times are.
|
|
]]
|
|
function LuaDateTime:GetLongRelativeTime(epoch, timezone, localeId)
|
|
if GetFFlagUseDateTimeType() then
|
|
-- Not relative time format for now, will do that later in DateTime v2
|
|
return self:Format("lll", timezone, localeId)
|
|
end
|
|
|
|
timezone = timezone or TimeZone.Current
|
|
epoch = epoch or LuaDateTime.now()
|
|
|
|
if self:IsSame(epoch, TimeUnit.Days, timezone) then
|
|
return self:Format("h:mm A", timezone)
|
|
elseif self:IsSame(epoch, TimeUnit.Weeks, timezone) then
|
|
return self:Format("DDD | h:mm A", timezone)
|
|
elseif self:IsSame(epoch, TimeUnit.Years, timezone) then
|
|
return self:Format("MMM D | h:mm A", timezone)
|
|
else
|
|
return self:Format("MMM D, YYYY | h:mm A", timezone)
|
|
end
|
|
end
|
|
|
|
--[[
|
|
Get a human-readable timestamp relative to the given epoch, which defaults
|
|
to now. The format of the time is contextual to how far away the times are.
|
|
]]
|
|
function LuaDateTime:GetShortRelativeTime(epoch, timezone, localeId)
|
|
timezone = timezone or TimeZone.Current
|
|
|
|
if GetFFlagUseDateTimeType() then
|
|
-- Not relative time format for now, will do that later in DateTime v2
|
|
return self:Format("ll", timezone, localeId)
|
|
end
|
|
|
|
epoch = epoch or LuaDateTime.now()
|
|
|
|
if self:IsSame(epoch, TimeUnit.Days, timezone) then
|
|
return self:Format("h:mm A", timezone)
|
|
elseif self:IsSame(epoch, TimeUnit.Weeks, timezone) then
|
|
return self:Format("DDD", timezone)
|
|
elseif self:IsSame(epoch, TimeUnit.Years, timezone) then
|
|
return self:Format("MMM D", timezone)
|
|
else
|
|
return self:Format("MMM D, YYYY", timezone)
|
|
end
|
|
end
|
|
|
|
LuaDateTime.__index = LuaDateTime
|
|
|
|
return LuaDateTime |