398 lines
No EOL
9.4 KiB
Lua
398 lines
No EOL
9.4 KiB
Lua
-- Date parsing and formatting utilities
|
|
local M = {}
|
|
|
|
-- Date format patterns
|
|
local DATE_PATTERNS = {
|
|
ISO_8601 = "^%d%d%d%d%-%d%d%-%d%d$",
|
|
ISO_8601_TIME = "^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%dZ?$",
|
|
ISO_8601_OFFSET = "^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%d[%+%-]%d%d:%d%d$",
|
|
RELATIVE = "^(%d+)([hdwmy])$",
|
|
NATURAL = "^(%d%d%d%d)%-(%d%d)%-(%d%d)$"
|
|
}
|
|
|
|
-- Month names
|
|
local MONTH_NAMES = {
|
|
"January", "February", "March", "April", "May", "June",
|
|
"July", "August", "September", "October", "November", "December"
|
|
}
|
|
|
|
local MONTH_SHORT = {
|
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
|
|
}
|
|
|
|
-- Parse date string to timestamp
|
|
function M.parse_date(date_string)
|
|
if not date_string or date_string == "" then
|
|
return nil
|
|
end
|
|
|
|
-- Handle relative dates
|
|
if date_string:match(DATE_PATTERNS.RELATIVE) then
|
|
return M.parse_relative_date(date_string)
|
|
end
|
|
|
|
-- Handle ISO 8601 with time
|
|
if date_string:match(DATE_PATTERNS.ISO_8601_TIME) then
|
|
return M.parse_iso8601_datetime(date_string)
|
|
end
|
|
|
|
-- Handle ISO 8601 with offset
|
|
if date_string:match(DATE_PATTERNS.ISO_8601_OFFSET) then
|
|
return M.parse_iso8601_with_offset(date_string)
|
|
end
|
|
|
|
-- Handle natural language dates
|
|
if date_string:match(DATE_PATTERNS.NATURAL) then
|
|
return M.parse_natural_date(date_string)
|
|
end
|
|
|
|
-- Handle ISO 8601 date only
|
|
if date_string:match(DATE_PATTERNS.ISO_8601) then
|
|
return M.parse_iso8601_date(date_string)
|
|
end
|
|
|
|
-- Handle common formats
|
|
return M.parse_common_formats(date_string)
|
|
end
|
|
|
|
-- Parse ISO 8601 date
|
|
function M.parse_iso8601_date(date_string)
|
|
local year, month, day = date_string:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
|
|
if not year then
|
|
return nil
|
|
end
|
|
|
|
local timestamp = os.time({
|
|
year = tonumber(year),
|
|
month = tonumber(month),
|
|
day = tonumber(day),
|
|
hour = 0,
|
|
min = 0,
|
|
sec = 0
|
|
})
|
|
|
|
return timestamp
|
|
end
|
|
|
|
-- Parse ISO 8601 datetime
|
|
function M.parse_iso8601_datetime(date_string)
|
|
local year, month, day, hour, min, sec, timezone = date_string:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(Z?)$")
|
|
if not year then
|
|
return nil
|
|
end
|
|
|
|
local timestamp = os.time({
|
|
year = tonumber(year),
|
|
month = tonumber(month),
|
|
day = tonumber(day),
|
|
hour = tonumber(hour),
|
|
min = tonumber(min),
|
|
sec = tonumber(sec)
|
|
})
|
|
|
|
return timestamp
|
|
end
|
|
|
|
-- Parse ISO 8601 with timezone offset
|
|
function M.parse_iso8601_with_offset(date_string)
|
|
local year, month, day, hour, min, sec, offset_sign, offset_hour, offset_min = date_string:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)([%+%-])(%d%d):(%d%d)$")
|
|
if not year then
|
|
return nil
|
|
end
|
|
|
|
local timestamp = os.time({
|
|
year = tonumber(year),
|
|
month = tonumber(month),
|
|
day = tonumber(day),
|
|
hour = tonumber(hour),
|
|
min = tonumber(min),
|
|
sec = tonumber(sec)
|
|
})
|
|
|
|
-- Apply timezone offset (convert to UTC)
|
|
local offset_total = tonumber(offset_hour) * 3600 + tonumber(offset_min) * 60
|
|
if offset_sign == "-" then
|
|
offset_total = -offset_total
|
|
end
|
|
|
|
timestamp = timestamp - offset_total
|
|
|
|
return timestamp
|
|
end
|
|
|
|
-- Parse natural date
|
|
function M.parse_natural_date(date_string)
|
|
local year, month, day = date_string:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
|
|
if not year then
|
|
return nil
|
|
end
|
|
|
|
-- Handle natural language month names
|
|
if not month then
|
|
local month_name, day_part, year_part = date_string:match("^(%a+)%s+(%d+)%s+(%d+)$")
|
|
if month_name and day_part and year_part then
|
|
month = M.get_month_number(month_name)
|
|
day = tonumber(day_part)
|
|
year = tonumber(year_part)
|
|
end
|
|
end
|
|
|
|
if year and month and day then
|
|
return os.time({
|
|
year = tonumber(year),
|
|
month = tonumber(month),
|
|
day = tonumber(day),
|
|
hour = 0,
|
|
min = 0,
|
|
sec = 0
|
|
})
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
-- Parse relative date
|
|
function M.parse_relative_date(date_string)
|
|
local amount, unit = date_string:match("^(%d+)([hdwmy])$")
|
|
if not amount or not unit then
|
|
return nil
|
|
end
|
|
|
|
local current_time = os.time()
|
|
amount = tonumber(amount)
|
|
|
|
local seconds = 0
|
|
if unit == "s" then
|
|
seconds = amount
|
|
elseif unit == "m" then
|
|
seconds = amount * 60
|
|
elseif unit == "h" then
|
|
seconds = amount * 3600
|
|
elseif unit == "d" then
|
|
seconds = amount * 86400
|
|
elseif unit == "w" then
|
|
seconds = amount * 604800
|
|
elseif unit == "m" then
|
|
seconds = amount * 2592000 -- 30 days
|
|
elseif unit == "y" then
|
|
seconds = amount * 31536000 -- 365 days
|
|
end
|
|
|
|
return current_time - seconds
|
|
end
|
|
|
|
-- Parse common formats
|
|
function M.parse_common_formats(date_string)
|
|
local formats = {
|
|
"^%d%d%d%d%/%d%d%/%d%d$", -- MM/DD/YYYY
|
|
"^%d%d%/%d%d%/%d%d%d%d$", -- M/D/YYYY
|
|
"^%d%d%-%d%d%-%d%d%d%d$", -- MM-DD-YYYY
|
|
}
|
|
|
|
for _, pattern in ipairs(formats) do
|
|
if date_string:match(pattern) then
|
|
-- Try to parse with Lua's built-in date parsing
|
|
local timestamp = os.time({
|
|
year = tonumber(date_string:sub(-4)),
|
|
month = tonumber(date_string:sub(1, 2)),
|
|
day = tonumber(date_string:sub(4, 5)),
|
|
hour = 0,
|
|
min = 0,
|
|
sec = 0
|
|
})
|
|
|
|
if timestamp > 0 then
|
|
return timestamp
|
|
end
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
-- Format timestamp to string
|
|
function M.format_date(timestamp, format)
|
|
format = format or "%Y-%m-%d"
|
|
|
|
if not timestamp then
|
|
return ""
|
|
end
|
|
|
|
return os.date(format, timestamp)
|
|
end
|
|
|
|
-- Get relative time string
|
|
function M.get_relative_time(timestamp)
|
|
local current_time = os.time()
|
|
local diff = current_time - timestamp
|
|
|
|
if diff < 60 then
|
|
return "just now"
|
|
elseif diff < 3600 then
|
|
local minutes = math.floor(diff / 60)
|
|
return string.format("%d minute%s ago", minutes, minutes > 1 and "s" or "")
|
|
elseif diff < 86400 then
|
|
local hours = math.floor(diff / 3600)
|
|
return string.format("%d hour%s ago", hours, hours > 1 and "s" or "")
|
|
elseif diff < 2592000 then
|
|
local days = math.floor(diff / 86400)
|
|
return string.format("%d day%s ago", days, days > 1 and "s" or "")
|
|
elseif diff < 31536000 then
|
|
local months = math.floor(diff / 2592000)
|
|
return string.format("%d month%s ago", months, months > 1 and "s" or "")
|
|
else
|
|
local years = math.floor(diff / 31536000)
|
|
return string.format("%d year%s ago", years, years > 1 and "s" or "")
|
|
end
|
|
end
|
|
|
|
-- Get month number from name
|
|
function M.get_month_number(month_name)
|
|
local lower_name = month_name:lower()
|
|
|
|
for i, name in ipairs(MONTH_NAMES) do
|
|
if name:lower() == lower_name then
|
|
return i
|
|
end
|
|
end
|
|
|
|
for i, name in ipairs(MONTH_SHORT) do
|
|
if name:lower() == lower_name then
|
|
return i
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
-- Get month name from number
|
|
function M.get_month_name(month_number, short)
|
|
if month_number < 1 or month_number > 12 then
|
|
return nil
|
|
end
|
|
|
|
if short then
|
|
return MONTH_SHORT[month_number]
|
|
else
|
|
return MONTH_NAMES[month_number]
|
|
end
|
|
end
|
|
|
|
-- Validate date string
|
|
function M.is_valid_date(date_string)
|
|
return M.parse_date(date_string) ~= nil
|
|
end
|
|
|
|
-- Add time to timestamp
|
|
function M.add_time(timestamp, amount, unit)
|
|
unit = unit or "days"
|
|
|
|
local seconds = 0
|
|
if unit == "seconds" then
|
|
seconds = amount
|
|
elseif unit == "minutes" then
|
|
seconds = amount * 60
|
|
elseif unit == "hours" then
|
|
seconds = amount * 3600
|
|
elseif unit == "days" then
|
|
seconds = amount * 86400
|
|
elseif unit == "weeks" then
|
|
seconds = amount * 604800
|
|
elseif unit == "months" then
|
|
seconds = amount * 2592000
|
|
elseif unit == "years" then
|
|
seconds = amount * 31536000
|
|
end
|
|
|
|
return timestamp + seconds
|
|
end
|
|
|
|
-- Get date range
|
|
function M.get_date_range(start_date, end_date)
|
|
local start_timestamp = M.parse_date(start_date)
|
|
local end_timestamp = M.parse_date(end_date)
|
|
|
|
if not start_timestamp or not end_timestamp then
|
|
return nil
|
|
end
|
|
|
|
return {
|
|
start_timestamp = start_timestamp,
|
|
end_timestamp = end_timestamp,
|
|
start_formatted = M.format_date(start_timestamp),
|
|
end_formatted = M.format_date(end_timestamp),
|
|
duration_days = math.floor((end_timestamp - start_timestamp) / 86400)
|
|
}
|
|
end
|
|
|
|
-- Get week start/end
|
|
function M.get_week_bounds(timestamp)
|
|
timestamp = timestamp or os.time()
|
|
|
|
local date_table = os.date("*t", timestamp)
|
|
local day_of_week = date_table.wday -- Sunday = 1, Monday = 2, etc.
|
|
|
|
-- Adjust to Monday = 0
|
|
day_of_week = (day_of_week + 5) % 7
|
|
|
|
local week_start = timestamp - (day_of_week * 86400)
|
|
local week_end = week_start + (6 * 86400)
|
|
|
|
return {
|
|
start_timestamp = week_start,
|
|
end_timestamp = week_end,
|
|
start_formatted = M.format_date(week_start),
|
|
end_formatted = M.format_date(week_end)
|
|
}
|
|
end
|
|
|
|
-- Get month start/end
|
|
function M.get_month_bounds(timestamp)
|
|
timestamp = timestamp or os.time()
|
|
local date_table = os.date("*t", timestamp)
|
|
|
|
local month_start = os.time({
|
|
year = date_table.year,
|
|
month = date_table.month,
|
|
day = 1,
|
|
hour = 0,
|
|
min = 0,
|
|
sec = 0
|
|
})
|
|
|
|
local next_month = os.time({
|
|
year = date_table.year,
|
|
month = date_table.month + 1,
|
|
day = 1,
|
|
hour = 0,
|
|
min = 0,
|
|
sec = 0
|
|
})
|
|
|
|
local month_end = next_month - 1
|
|
|
|
return {
|
|
start_timestamp = month_start,
|
|
end_timestamp = month_end,
|
|
start_formatted = M.format_date(month_start),
|
|
end_formatted = M.format_date(month_end)
|
|
}
|
|
end
|
|
|
|
-- Get time zones
|
|
function M.get_timezones()
|
|
return {
|
|
"UTC",
|
|
"America/New_York",
|
|
"America/Chicago",
|
|
"America/Denver",
|
|
"America/Los_Angeles",
|
|
"Europe/London",
|
|
"Europe/Paris",
|
|
"Asia/Tokyo",
|
|
"Australia/Sydney"
|
|
}
|
|
end
|
|
|
|
return M |