-- FILE:     DATETIME.E
-- PURPOSE:  Date and Time routines for Euphoria 3.1.1.
-- AUTHOR:   Shian Lee
-- VERSION:  1.20  January/9/2018
-- LANGUAGE: Euphoria version 3.1.1 (http://www.RapidEuphoria.com)
-- PLATFORM: Any
-- LICENCE:  Free. Use at your own risk.
-- NOTE:     * See the attached reference manual DATETIME.DOC for details.
--           * Created using QE v2.3.9 for DOS (RapidEuphoria.com archive).
-- DETAILS:  This library supports Gregorian Calendar date/time in the range
--           1/1/1900 to 12/31/4900. All functions must return 0 or 0's when
--           result is out of range.
--           All global functions accept or return a date/time serial number.
--           Serial number is an atom of the form (JDN.seconds).
--           The integer part (2415021..3511113) is a Julian Day Number (JDN)
--           which is equivalent to {year, month, day}. The fractional part
--           (0..0.99999) is seconds-per-day which is equivalent to
--           {hour, minute, second}.
-- HISTORY:  v1.00 - initial version.
--           v1.10 - * time_to_float32() removed. not accurate with IEEE-32.
--                   * dateinfo()/datediff() - skip 0:00:00/negative value.
--           v1.20 - * date_to_float64()/date_to_float32() removed since you
--                     can use atom_to_float64/32() directly. DOC updated.
--                   * short years, "17", in sprintd() is optimized.
-----------------------------------------------------------------------------



--bookmark: Local Constants;

constant
    OUT_OF_RANGE   = 0,                     -- 0 is always returned on error
    OUT_OF_RANGE_7 = repeat(OUT_OF_RANGE, 7),

    COMMON_YEAR = 365,                      -- days in a common year
    LEAP_YEAR   = 366,                      -- days in a leap year (Feb is 29)

    DAYS_PER_MONTH = {                      -- days in a month (common year)
	31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
   -- {Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec}
    },

    DAYS_PER_MONTH_SUM = {                  -- sum of days in a month
	0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334
  -- {Jan,Feb,Mar,Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec}
    },

    FEBRUARY = 2,                           -- month index

					    -- 1 second (0.99999 is 23:59:59),
    ONE_SEC      = 0.0000115740740740741,   -- 1 / 86400 = one second
    HALF_SEC     = 0.0000057870370370370,   -- ONE_SEC / 2 = half a second
    SEC_PER_MIN  = 0.0006944444444444460,   -- 60*ONE_SEC, seconds per minute
    SEC_PER_HOUR = 0.0416666666666667600,   -- 3600*ONE_SEC, seconds per hour
    SEC_IN_DAY   = 0.9999884259259282000,   -- 86399*ONE_SEC, seconds in a day

    FIRST_YEAR = 1900,                      -- first supported year
    LAST_YEAR  = 4900,                      -- last year (avoid any overflow)
    FIRST_JDN  = 2415021,                   -- JDN of 01-01-1900
    LAST_JDN   = 3511113,                   -- JDN of 12-31-4900

    DEF_DATE_VALUES = {                     -- default for date-time sequence
	FIRST_YEAR, 1, 1, 0, 0, 0           -- {year, month, day, h, m, s}
    },
					    -- indexes for date-time sequence
    Y = 1,      --          { year,  -- 4 digits year number (e.g. 2017)
    M = 2,      --           month,  -- January = 1
    D = 3,      --             day,  -- day of month, starting at 1
    H = 4,      --            hour,  -- 0 to 23
    T = 5,      --          minute,  -- 0 to 59
    S = 6,      --          second,  -- 0 to 59
    W = 7,      -- day of the week,  -- Sunday = 1
    E = 8,      -- day of the year,  -- January 1st = 1
    O = 9,      -- days in a month,  -- 1 to 31
    A = 10,     -- days in a year}   -- 365 or 366 (common or leap year)

    DAY_NAMES = {"???",                     -- "???" for OUT_OF_RANGE (0)
	"Sunday", "Monday", "Tuesday", "Wednesday",
	"Thursday", "Friday", "Saturday"
    },

    MONTH_NAMES = {"???",                   -- "???" for OUT_OF_RANGE (0)
	"January", "February", "March", "April", "May", "June",
	"July", "August", "September", "October", "November", "December"
    },

    AMPM = {"am", "pm", "AM", "PM"}         -- used by sprintd()



--bookmark: Julian Day Number (JDN);

-- PURPOSE: convert year, month, day to Julian Day Number (JDN)
-- CREDIT:  Microsoft(R) QuickBASIC Programmer's Toolbox, By John Clark Craig
-- NOTE:    calculates the astronomical Julian day number for any date from
--          January 1, 1583, into the future, accounting for leap years and
--          century adjustments, according to Gregorian Calendar.
-- DETAILS: y,m,d are year, month, day (day can be any +/-integer)
-- RETURN:  atom; Julian day number, or 0 if argument/result is out-of-range
function date_to_jdn(integer y, integer m, integer d)
    integer j

    if y >= FIRST_YEAR and y <= LAST_YEAR and m >= 1 and m <= 12 then
	if m > 2 then
	   m -= 3
	else
	   m += 9
	   y -= 1
	end if

	j = floor(146097 * floor(y / 100) / 4) +
	    floor(1461 * remainder(y, 100) / 4) +
	    floor((153 * m + 2) / 5) + d + 1721119

	if j >= FIRST_JDN and j <= LAST_JDN then
	    return j                        -- return Julian Day Number
	end if
    end if

    return OUT_OF_RANGE                     -- argument/result out of range
end function



-- PURPOSE: convert a Julian Day Number (JDN) to {year, month, day}
-- CREDIT:  Microsoft(R) QuickBASIC Programmer's Toolbox, By John Clark Craig
-- NOTE:    calculation is according to Gregorian Calendar
-- DETAILS: j is a Julian day number, returned by date_to_jdn()
-- RETURN:  sequence; {year, month, day}, or {0, 0, 0} if j is out-of-range
function jdn_to_date(integer j)
    integer y, m, d
    atom a

    if j >= FIRST_JDN and j <= LAST_JDN then

	a = 4 * j - 6884477
	d = floor(remainder(a, 146097) / 4)
	y = floor(a / 146097) * 100

	a = 4 * d + 3
	d = floor(remainder(a, 1461) / 4) + 1
	y += floor(a / 1461)

	a = 5 * d - 3
	d = floor(remainder(a, 153) / 5) + 1
	m = floor(a / 153) + 1
	y += floor(m / 11)

	if m < 11 then                      -- return {year, month, day}
	    return {y, m + 2, d}
	else
	    return {y, m - 10, d}
	end if
    else
	return repeat(OUT_OF_RANGE, 3)      -- return {0, 0, 0}
    end if
end function



--bookmark: Serial Number;

-- PURPOSE: return a serial number equal to current date/time
-- RETURN:  atom; date/time serial number
global function now()
    sequence s

    s = date()

    return date_to_jdn(s[Y] + 1900, s[M], s[D]) +
	s[H] * SEC_PER_HOUR + s[T] * SEC_PER_MIN + s[S] * ONE_SEC
end function



-- PURPOSE: return a serial number from a date/time sequence
-- NOTE:    any argument outside valid range is evaluated to calculate a valid
--          date and time according to the Gregorian calendar.
-- DETAILS: s is date/time sequence {year, month, day, hour, minute, second}
--          * default value is used for missing fields; extra fields ignored
-- RETURN:  atom; date/time serial number (JDN.seconds-per-day), or
--                if s is out-of-range return (0.seconds-per-day)
global function datetime(sequence s)
    atom a

    s = floor(s)                            -- calculation is for integers

    if length(s) < 6 then                   -- use default values
	s &= DEF_DATE_VALUES[length(s) + 1..6]
    end if
					    -- get total seconds (h+m+s)
    s[S] = s[H] * SEC_PER_HOUR + s[T] * SEC_PER_MIN + s[S] * ONE_SEC

    if s[S] < 0 or s[S] > SEC_IN_DAY then   -- convert extra seconds to days:
	a = remainder(s[S], 1)              -- get seconds-per-day
	if a > 0 then                       -- this day:
	    s[D] += (s[S] - a)              --   seconds to days
	    s[S] = a                        --   seconds-per-day
	elsif a < 0 then                    -- previous day:
	    s[D] += (s[S] - a) - 1          --   seconds to days
	    s[S] = 1 + a                    --   seconds-per-day
	end if
    end if

    if s[M] < 1 or s[M] > 12 then           -- convert extra months to years:
	s[Y] += floor(s[M] / 12)            -- months to years
	s[M] = remainder(s[M], 12)          -- months-per-year (1..11)
	if not s[M] then                    -- month is 12 this/previous year
	    s[Y] -= 1
	    s[M] = 12
	elsif s[M] < 0 then                 -- month is -1..-11 previous year
	    s[M] = 12 + s[M]
	end if
    end if
					    -- return JDN.Seconds
    return date_to_jdn(s[Y], s[M], s[D]) + s[S]
end function



--bookmark: Date/Time Info;

-- PURPOSE: return a date/time sequence from a serial number
-- NOTE:    leap year has 366 days (February has 29 days instead of 28):
--          if a year is evenly divisible by 4 and not divisible by 100, or
--          if a year is evenly divisible by 400, then it's a leap year
-- DETAILS: sn is date/time serial number
-- RETURN:  sequence; { year, [Y] -- 4 digits year number (e.g. 2017)
--                     month, [M] -- January = 1
--                       day, [D] -- day of month, starting at 1
--                      hour, [H] -- 0 to 23
--                    minute, [T] -- 0 to 59
--                    second, [S] -- 0 to 59
--           day of the week, [W] -- Sunday = 1
--           day of the year, [E] -- January 1st = 1
--           days in a month, [O] -- 1 to 31
--           days in a year}  [A] -- 365 or 366 (common or leap year)
--           * if sn is out-of-range then date/time fields may be 0.
global function dateinfo(atom sn)
    sequence s
    atom  t
					    -- get {y, m, d} & init to 0
    s = jdn_to_date(floor(sn)) & OUT_OF_RANGE_7

    if s[Y] then                            -- year is in range?
	if remainder(s[Y], 4) then          -- get days in a year:
	    s[A] = COMMON_YEAR              -- y *not* divisible by 4 (or 400)
	elsif remainder(s[Y], 100) then     -- y divisible by 4 &
	    s[A] = LEAP_YEAR                -- y *not* divisible by 100
	elsif remainder(s[Y], 400) then
	    s[A] = COMMON_YEAR              -- y *not* divisible by 400
	else
	    s[A] = LEAP_YEAR                -- y divisible by 400
	end if
					    -- get days in a month [O] and
	if s[A] = LEAP_YEAR then            -- get day of the year [E]
	    s[O] = DAYS_PER_MONTH[s[M]] + (s[M] = FEBRUARY)
	    s[E] = DAYS_PER_MONTH_SUM[s[M]] + s[D] + (s[M] > FEBRUARY)
	else
	    s[O] = DAYS_PER_MONTH[s[M]]
	    s[E] = DAYS_PER_MONTH_SUM[s[M]] + s[D]
	end if

	s[W] = remainder(floor(sn) + 1, 7) + 1  -- get day of the week
    end if
					    -- * copied To datediff() as is:
    t = remainder(sn, 1)                    -- get seconds-per-day
    if t > 0 then                           -- skip 0:00:00/negative value
	s[H] = remainder(floor(             -- get round(hour)
	    (t / SEC_PER_HOUR) + HALF_SEC
	), 24)
	s[T] = remainder(floor(             -- get round(minute)
	    (remainder(t, SEC_PER_HOUR) / SEC_PER_MIN) + HALF_SEC
	), 60)
	s[S] = remainder(floor(             -- get round(second)
	    (remainder(t, SEC_PER_MIN) / ONE_SEC) + 0.5
	), 60)
    end if

    return s
end function



-- PURPOSE: return the difference between two serial numbers
-- DETAILS: sn,sn2 are date/time serial number
-- RETURN:  sequence; absolute difference in {days, hours, minutes, seconds},
--                    or if sn/sn2 is out-of-range return {0, 0, 0, 0}
global function datediff(atom sn, atom sn2)
    sequence s
    atom t

    if not (                                -- check if arguments are in range
	sn >= FIRST_JDN and floor(sn) <= LAST_JDN and
	sn2 >= FIRST_JDN and floor(sn2) <= LAST_JDN
    ) then
	return repeat(OUT_OF_RANGE, 4)      -- argument(s) out of range
    end if

    sn -= sn2                               -- get difference value (JDN.sec)
    if sn < 0 then sn = -sn end if          -- convert to absolute value

    s = repeat(OUT_OF_RANGE, S)             -- init {Y,M,D, H,T,S}

					    -- * copied From dateinfo() as is:
    t = remainder(sn, 1)                    -- get seconds-per-day
    if t > 0 then                           -- skip 0:00:00/negative value
	s[H] = remainder(floor(             -- get round(hour)
	    (t / SEC_PER_HOUR) + HALF_SEC
	), 24)
	s[T] = remainder(floor(             -- get round(minute)
	    (remainder(t, SEC_PER_HOUR) / SEC_PER_MIN) + HALF_SEC
	), 60)
	s[S] = remainder(floor(             -- get round(second)
	    (remainder(t, SEC_PER_MIN) / ONE_SEC) + 0.5
	), 60)
    end if

    return floor(sn) & s[H..S]              -- difference in {days, h, m, s}
end function



-- PURPOSE: return a formatted string sequence from a serial number
-- DETAILS: sn is date/time serial number;
--          p is format pattern; format specifiers are:
--          sthHdmywecMY -- 1 to 5 leading zeros allowed (%s,%ss,...,%sssss)
--          pPn%$        -- leading zeros *not* allowed (%p)
--           Details:
--           %s - seconds           %d - days
--           %t - minutes           %m - months
--           %h - hours             %y - years
--           %H - 12-hours clock    %w - day of the week
--           %p - am/pm             %e - day of the year
--           %P - AM/PM             %c - day of the century
--           %n - serial number     %M - days in a month
--           %% - '%'               %Y - days in a year (365/366)
--           %$ - "" (end of format specifier)
-- RETURN:   string; formatted date/time
-- EXAMPLE:  s = sprintd("%dddd, %mmmm/%d/%yyyy  %h:%tt\n", sn)
--            -- s is "Tuesday, February/3/2017  14:53\n"
constant
    FIVE_CHARS_AHEAD = repeat(1, 5),
    F = {"%01d", "%02d", "%03d", "%04d", "%05d"}    -- sprintf() formats
global function sprintd(sequence p, atom sn)
    sequence s, r
    integer len, i, c, z                    -- length, index, char, zeros

    len = length(p)                         -- length of format pattern
    p &= FIVE_CHARS_AHEAD                   -- for checking 5 chars ahead
    s = dateinfo(sn)                        -- s is {y, m, d, h, m, s, w,...}
    r = {}                                  -- init return string
    i = 1                                   -- init loop index

    while i <= len do                       -- loop to parse format pattern
	if p[i] = '%' then                  -- '%' starts format specifier
	    c = p[i + 1]                    -- 'y'/'m'/etc

	    if find(c, "sthHdmywecMY") then -- 1 to 5 leading zeros allowed:
		if p[i + 2] = c then
		    z = 2                   -- "%xx"
		    if p[i + 3] = c then
			z = 3               -- "%xxx"
			if p[i + 4] = c then
			    z = 4           -- "%xxxx"
			    if p[i + 5] = c then
				z = 5       -- "%xxxxx"
			    end if
			end if
		    end if
		else
		    z = 1                   -- "%x"
		end if

		i += z                      -- update loop index

		if c = 's' then             -- seconds (time first for speed)
		    r &= sprintf(F[z], s[S])
		elsif c = 't' then          -- minutes
		    r &= sprintf(F[z], s[T])
		elsif c = 'h' then          -- hours
		    r &= sprintf(F[z], s[H])
		elsif c = 'H' then          -- hours (12-hours clock)
		    r &= sprintf(F[z], remainder(s[H], 12))
		elsif c = 'd' then          -- days
		    if z = 4 then           -- "Sunday"
			r &= DAY_NAMES[s[W] + 1]
		    elsif z = 3 then        -- "Sun"
			r &= DAY_NAMES[s[W] + 1][1..3]
		    else
			r &= sprintf(F[z], s[D])
		    end if
		elsif c = 'm' then          -- months
		    if z = 4 then           -- "January"
			r &= MONTH_NAMES[s[M] + 1]
		    elsif z = 3 then        -- "Jan"
			r &= MONTH_NAMES[s[M] + 1][1..3]
		    else
			r &= sprintf(F[z], s[M])
		    end if
		elsif c = 'y' then          -- years
		    if z >= 4 then          -- "2017"
			r &= sprintf(F[z], s[Y])
		    else                    -- "17"
			r &= sprintf(F[z], remainder(s[Y], 100))
		    end if
		elsif c = 'w' then          -- day of the week
		    r &= sprintf(F[z], s[W])
		elsif c = 'e' then          -- day of the year
		    r &= sprintf(F[z], s[E])
		elsif c = 'c' then          -- day of the century
		    r &= sprintf(F[z], floor(sn) + 1 -
			date_to_jdn(s[Y] - remainder(s[Y], 100), 1, 1))
		elsif c = 'M' then          -- days in a month
		    r &= sprintf(F[z], s[O])
		elsif c = 'Y' then          -- days in a year
		    r &= sprintf(F[z], s[A])
		end if
	    else                            -- leading zeros *not* allowed:
		i += 1                      -- update loop index

		if c = 'p' then             -- am/pm
		    r &= AMPM[1 + (s[H] >= 12)]
		elsif c = 'P' then          -- AM/PM
		    r &= AMPM[3 + (s[H] >= 12)]
		elsif c = 'n' then          -- date/time serial number
		    r &= sprintf("%013.5f", sn)
		elsif c = '%' then          -- the character '%'
		    r &= '%'
		elsif c = '$' then          -- end of format specifier
		 -- r &= ""
		else
		    r &= '%'                -- error: format char is missing
		    i -= 1                  -- update loop index to next char
		end if
	    end if
	else
	    r &= p[i]                       -- append regular character
	end if

	i += 1                              -- update loop index
    end while

    return r
end function



-- End of file.
