Dates

This module provides minimal date time records and procedures for working with dates in YYYY-MM-DD and MM/DD/YYYY format and times in H:MM, HH:MM and HH:MM:SS formats.

Set
Set a DateTime record providing year, month, day, hour, minute and second as integers and the DateTime record to be populated.
SetDate
Set the date portion of a DateTime record, leaves the hour, minute and second attributes unmodified.
SetTime
Set the time portion of a DateTime record, leaves the year, month, date attributes unmodified.
Copy
Copy the attributes of one DateTime record into another DateTime record
ToChars
Given a DateTime record and a format constant, render the DateTime record to an array of CHAR.
LeapYear
Given a DateTime record check to see if it is a leap year.
NumOfDays
Given a year and monoth return the number of days in the month.
IsValid
Check to see if the all attributes in a DateTime record are valid.
OberonToDateTime
Convert oberon date and time integer values into a DateTime record
DateTimeToOberon
Convert a DateTime record into Oberon date and time integer values.
Now
Set a DateTime record’s attributes to the current time, depends of on the implementation of Clock.Mod.
WeekDate
Given a DateTime record calculates the year, week and weekday as integers values.
Equal
Checks to DateTime records to see if they have equivalent attribute values.
Compare
Compare two DateTime records, if t1 < t2 then return -1, if t1 = t2 return 0 else if t1 > t2 return 1.
CompareDate
Compare the year, month, day attributes of two DateTime records following the approach used in Compare.
CompareTime
Compare the hour, minute, second attributes of two DateTime records following the approach used in Compare.
TimeDifference
Take the differ of two DateTime records setting the difference in integer values for days, hours, minutes and seconds.
AddYears
Add years to a DateTime record. Years is a positive or negative integer.
AddMonths
Add months to a DateTime record. Months is either a positive or negative integer. Months will propogate to year in the DataTime record.
AddDays
Add days to a DateTime record. Days can be either a positive or negative integer. Days will propogate to month and year attributes of the DateTime record.
AddHours
Add hours to a DateTime record. Hours can be either a positive or negative integer. Hours will propogate to day, month and year attributes of the DateTime record.
AddMinutes
Add minutes to a DateTime record. Minutes can be either a positive or negative integer. Minutes will propogate to hour, day, month and year attributes of the DateTime record.
AddSeconds
Add seconds to a DateTime record. Seconds can be either a positive or negatice integer. Seconds will propogate to minute, hour, day, month, year attributes of the DateTime record.
IsValidDate
IsValidDate checks the day, month, year attributes of a DateTime record and validates the values. Returns TRUE if everthing is ok, FALSE otherwise.
IsValidTime
IsValidTime checks the hour, minute, second attributes of a DateTime record and validates the values. Returns TRUE if everthing is ok, FALSE otherwise.
IsDateString
Checks to see if an ARRAY OF CHAR is a parsiable date string (e.g. in 2020-11-26 or 11/26/2020). Returns TRUE if the string is parsable, FALSE otherwise. NOTE: It does NOT check to see if the day, month or year values are valid. It only checks the format of the string.
IsTimeString
Checks to see if an ARRAY OF CHAR is a parsible time string (e.g. 3:32, 14:55, 09:19:22). NOTE: It only checks the format and does not check the hour, minute and second values.
ParseDate
Parse an ARRAY OF CHAR setting the values if year, month and day. Return TRUE on successful parse and FALSE otherwise.
ParseTime
Parse an ARRAY OF CHAR setting the values of hour, minute and second. Return TRUE on succesful parse and FALSE otherwise.
Parse
Parse an ARRAY OF CHAR setting the attributes of a DateTime record. Return TURE on success, FALSE otherwise.

Limitations

Dates are presumed to be in the YYYY-DD-MM or MM/DD/YYYY formats. Does not handle dates with spelled out months or weekdays.

Time portion of the date object doesn’t include time zone. This will need to be rectified at some point.

Source code for Dates.Mod

(* Dates -- this module was inspired by the A2's Dates module, adapted
   for Oberon-7 and a POSIX system. It provides an assortment of procedures
   for working with a simple datetime record.

Copyright (C) 2020 R. S. Doiel

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.


@Author R. S. Doiel, <rsdoiel@gmail.com>
copyright (c) 2020, all rights reserved.
This software is released under the GNU AGPL
See http://www.gnu.org/licenses/agpl-3.0.html
*)
MODULE Dates;
IMPORT Chars, Strings, Clock, Convert := extConvert; (*, Out; **)

CONST
    MAXSTR = Chars.MAXSTR;
    SHORTSTR = Chars.SHORTSTR;

    YYYYMMDD* = 1; (* YYYY-MM-DD format *)
    MMDDYYYY* = 2; (* MM/DD/YYYY format *)
    YYYYMMDDHHMMSS* = 3; (* YYYY-MM-DD HH:MM:SS format *)

TYPE
    DateTime* = RECORD
        year*, month*, day*, hour*, minute*, second* : INTEGER
    END;

VAR
    (* Month names, January = 0, December = 11 *)
    Months*: ARRAY 23 OF ARRAY 10 OF CHAR; 
    (* Days of week, Monday = 0, Sunday = 6 *)
    Days*: ARRAY 7 OF ARRAY 10 OF CHAR;
    DaysInMonth: ARRAY 12 OF INTEGER;


(* Set -- initialize a date record year, month and day values *)
PROCEDURE Set*(year, month, day, hour, minute, second : INTEGER; VAR dt: DateTime);
BEGIN
    dt.year := year;
    dt.month := month;
    dt.day := day;
    dt.hour := hour;
    dt.minute := minute;
    dt.second := second;
END Set;

(* SetDate -- set a Date record's year, month and day attributes *)
PROCEDURE SetDate*(year, month, day : INTEGER; VAR dt: DateTime);
BEGIN
    dt.year := year;
    dt.month := month;
    dt.day := day;
END SetDate;

(* SetTime -- set a Date record's hour, minute, second attributes *)
PROCEDURE SetTime*(hour, minute, second : INTEGER; VAR dt: DateTime);
BEGIN
    dt.hour := hour;
    dt.minute := minute;
    dt.second := second;
END SetTime;

(* Copy -- copy the values from one date record to another *)
PROCEDURE Copy*(src : DateTime; VAR dest : DateTime);
BEGIN
    dest.year := src.year;
    dest.month := src.month;
    dest.day := src.day;
    dest.hour := src.hour;
    dest.minute := src.minute;
    dest.second := src.second;
END Copy;

(* ToChars -- converts a date record into an array of chars using
the format constant. Formats supported are YYYY-MM-DD HH:MM:SS
or MM/DD/YYYY HH:MM:SS. *)
PROCEDURE ToChars*(dt: DateTime; fmt : INTEGER; VAR src : ARRAY OF CHAR);
VAR ok : BOOLEAN;
BEGIN
    Chars.Clear(src);
    IF fmt = YYYYMMDD THEN
        Chars.AppendInt(dt.year, 4, "0", src);
        ok := Chars.AppendChar("-", src);
        Chars.AppendInt(dt.month, 2, "0", src);
        ok := Chars.AppendChar("-", src);
        Chars.AppendInt(dt.day, 2, "0", src);
    ELSIF fmt = MMDDYYYY THEN
        Chars.AppendInt(dt.month, 2, "0", src);
        ok := Chars.AppendChar("/", src);
        Chars.AppendInt(dt.day, 2, "0", src);
        ok := Chars.AppendChar("/", src);
        Chars.AppendInt(dt.year, 4, "0", src);
    ELSIF fmt = YYYYMMDDHHMMSS THEN
        Chars.AppendInt(dt.year, 4, "0", src);
        ok := Chars.AppendChar("-", src);
        Chars.AppendInt(dt.month, 2, "0", src);
        ok := Chars.AppendChar("-", src);
        Chars.AppendInt(dt.day, 2, "0", src);
        ok := Chars.AppendChar(" ", src);
        Chars.AppendInt(dt.hour, 2, "0", src);
        ok := Chars.AppendChar(":", src);
        Chars.AppendInt(dt.minute, 2, "0", src);
        ok := Chars.AppendChar(":", src);
        Chars.AppendInt(dt.second, 2, "0", src);
    END;
END ToChars;

(* 
 * Date and Time functions very much inspired by A2 but
 * adapted for use in Oberon-7 and OBNC compiler.
 *)

(* LeapYear -- returns TRUE if 'year' is a leap year *)
PROCEDURE LeapYear*(year: INTEGER): BOOLEAN;
BEGIN
    RETURN (year > 0) & (year MOD 4 = 0) & (~(year MOD 100 = 0) OR (year MOD 400 = 0))
END LeapYear;

(* NumOfDays -- number of days, returns the number of days in that month *)
PROCEDURE NumOfDays*(year, month: INTEGER): INTEGER;
VAR result : INTEGER;
BEGIN
    result := 0;
    DEC(month);
    IF ((month >= 0) & (month < 12)) THEN
        IF (month = 1) & LeapYear(year) THEN 
            result := DaysInMonth[1]+1;
        ELSE 
            result := DaysInMonth[month];
        END;
    END;
    RETURN result
END NumOfDays;

(* IsValid -- checks if the attributes set in a DateTime record are valid *)
PROCEDURE IsValid*(dt: DateTime): BOOLEAN;
BEGIN
    RETURN ((dt.year > 0) & (dt.month > 0) & (dt.month <= 12) & (dt.day > 0) & (dt.day <= NumOfDays(dt.year, dt.month)) & (dt.hour >= 0) & (dt.hour < 24) & (dt.minute >= 0) & (dt.minute < 60) & (dt.second >= 0) & (dt.second < 60))
END IsValid;

(* IsValidDate -- checks to see if a datetime record has valid day, month and year
attributes *)
PROCEDURE IsValidDate*(dt: DateTime) : BOOLEAN;
BEGIN
    RETURN (dt.year > 0) & (dt.month > 0) & (dt.month <= 12) & (dt.day > 0) & (dt.day <= NumOfDays(dt.year, dt.month))
END IsValidDate;

(* IsValidTime -- checks if the hour, minute, second attributes set in a DateTime record are valid *)
PROCEDURE IsValidTime*(dt: DateTime): BOOLEAN;
BEGIN
    RETURN (dt.hour >= 0) & (dt.hour < 24) & (dt.minute >= 0) & (dt.minute < 60) & (dt.second >= 0) & (dt.second < 60)
END IsValidTime;


(* OberonToDateTime -- convert an Oberon date/time to a DateTime 
structure *)
PROCEDURE OberonToDateTime*(Date, Time: INTEGER; VAR dt : DateTime);
BEGIN
    dt.second := Time MOD 64; Time := Time DIV 64;
    dt.minute := Time MOD 64; Time := Time DIV 64;
    dt.hour := Time MOD 24;
    dt.day := Date MOD 32; Date := Date DIV 32;
    dt.month := (Date MOD 16) + 1; Date := Date DIV 16;
    dt.year := Date;
END OberonToDateTime;

(* DateTimeToOberon -- convert a DateTime structure to an Oberon 
date/time *)
PROCEDURE DateTimeToOberon*(dt: DateTime; VAR date, time: INTEGER);
BEGIN
    IF IsValid(dt) THEN
    date := (dt.year)*512 + dt.month*32 + dt.day;
    time := dt.hour*4096 + dt.minute*64 + dt.second
    ELSE
        date := 0;
        time := 0;
    END;
END DateTimeToOberon;

(* Now -- returns the current date and time as a DateTime record. *)
PROCEDURE Now*(VAR dt: DateTime);
VAR d, t: INTEGER;
BEGIN
    Clock.Get(t, d);
    OberonToDateTime(d, t, dt);
END Now;


(* WeekDate -- returns the ISO 8601 year number, week number &
week day (Monday=1, ....Sunday=7) 
Algorithm is by Rick McCarty, http://personal.ecu.edu/mccartyr/ISOwdALG.txt
*)
PROCEDURE WeekDate*(dt: DateTime; VAR year, week, weekday: INTEGER);
VAR doy, i, yy, c, g, jan1: INTEGER; leap: BOOLEAN;
BEGIN
    IF IsValid(dt) THEN
        leap := LeapYear(dt.year);
        doy := dt.day; i := 0;
        WHILE (i < (dt.month - 1)) DO 
            doy := doy + DaysInMonth[i];
            INC(i);
        END;
        IF leap & (dt.month > 2) THEN 
            INC(doy);
        END;
        yy := (dt.year - 1) MOD 100; 
        c := (dt.year - 1) - yy; 
        g := (yy + yy) DIV 4;
        jan1 := 1 + (((((c DIV 100) MOD 4) * 5) + g) MOD 7);

        weekday := 1 + (((doy + (jan1 - 1)) - 1) MOD 7);

        IF (doy <= (8 - jan1)) & (jan1 > 4) THEN (* falls in year-1 ? *)
            year := dt.year - 1;
            IF (jan1 = 5) OR ((jan1 = 6) & LeapYear(year)) THEN 
                week := 53;
            ELSE 
                week := 52;
            END;
        ELSE
            IF leap THEN 
                i := 366;
            ELSE 
                i := 365;
            END;
            IF ((i - doy) < (4 - weekday)) THEN
                year := dt.year + 1;
                week := 1;
            ELSE
                year := dt.year;
                i := doy + (7-weekday) + (jan1-1);
                week := i DIV 7;
                IF (jan1 > 4) THEN 
                    DEC(week);
                END;
            END;
        END;
    ELSE
        year := -1; week := -1; weekday := -1;
    END;
END WeekDate;

(* Equal -- compare to date records to see if they are equal values *)
PROCEDURE Equal*(t1, t2: DateTime) : BOOLEAN;
BEGIN
    RETURN ((t1.second = t2.second) & (t1.minute = t2.minute) & (t1.hour = t2.hour) & (t1.day = t2.day) & (t1.month = t2.month) & (t1.year = t2.year))
END Equal;

(* compare -- used in Compare only for comparing specific values,
    returning an appropriate -1, 0, 1 *)
PROCEDURE compare(t1, t2 : INTEGER) : INTEGER;
VAR result : INTEGER;
BEGIN
    IF (t1 < t2) THEN 
        result := -1;
    ELSIF (t1 > t2) THEN 
        result := 1;
    ELSE 
        result := 0;
    END;
    RETURN result
END compare;

(* Compare -- returns -1 if (t1 < t2), 0 if (t1 = t2) or 1 if (t1 >  t2) *)
PROCEDURE Compare*(t1, t2: DateTime) : INTEGER;
VAR result : INTEGER;
BEGIN
    result := compare(t1.year, t2.year);
    IF (result = 0) THEN
        result := compare(t1.month, t2.month);
        IF (result = 0) THEN
            result := compare(t1.day, t2.day);
            IF (result = 0) THEN
                result := compare(t1.hour, t2.hour);
                IF (result = 0) THEN
                    result := compare(t1.minute, t2.minute);
                    IF (result = 0) THEN
                        result := compare(t1.second, t2.second);
                    END;
                END;
            END;
        END;
    END;
    RETURN result
END Compare;

(* CompareDate -- compare day, month and year values only *)
PROCEDURE CompareDate*(t1, t2: DateTime) : INTEGER;
VAR result : INTEGER;
BEGIN
    result := compare(t1.year, t2.year);
    IF (result = 0) THEN
        result := compare(t1.month, t2.month);
        IF (result = 0) THEN
            result := compare(t1.day, t2.day);
        END;
    END;
    RETURN result
END CompareDate;

(* CompareTime -- compare second, minute and hour values only *)
PROCEDURE CompareTime*(t1, t2: DateTime) : INTEGER;
VAR result : INTEGER;
BEGIN
    result := compare(t1.hour, t2.hour);
    IF (result = 0) THEN
        result := compare(t1.minute, t2.minute);
        IF (result = 0) THEN
            result := compare(t1.second, t2.second);
        END;
    END;
    RETURN result
END CompareTime;



(* TimeDifferences -- returns the absolute time difference between 
t1 and t2.

Note that leap seconds are not counted, 
see http://www.eecis.udel.edu/~mills/leap.html *)
PROCEDURE TimeDifference*(t1, t2: DateTime; VAR days, hours, minutes, seconds : INTEGER);
CONST SecondsPerMinute = 60; SecondsPerHour = 3600; SecondsPerDay = 86400;
VAR start, end: DateTime; year, month, second : INTEGER;
BEGIN
    IF (Compare(t1, t2) = -1) THEN 
        start := t1; 
        end := t2; 
    ELSE 
        start := t2; 
        end := t1; 
    END;
    IF (start.year = end.year) & (start.month = end.month) & (start.day = end.day) THEN
        second := end.second - start.second + ((end.minute - start.minute) * SecondsPerMinute) + ((end.hour - start.hour) * SecondsPerHour);
        days := 0;
        hours := 0;
        minutes := 0;
    ELSE
        (* use start date/time as reference point *)
        (* seconds until end of the start.day *)
        second := (SecondsPerDay - start.second) - (start.minute * SecondsPerMinute) - (start.hour * SecondsPerHour);
        IF (start.year = end.year) & (start.month = end.month) THEN
            (* days between start.day and end.day *)
            days := (end.day - start.day) - 1;
        ELSE
            (* days until start.month ends excluding start.day *)
            days := NumOfDays(start.year, start.month) - start.day;
            IF (start.year = end.year) THEN
                (* months between start.month and end.month *)
                FOR month := start.month + 1 TO end.month - 1 DO
                    days := days + NumOfDays(start.year, month);
                END;
            ELSE
                (* days until start.year ends (excluding start.month) *)
                FOR month := start.month + 1 TO 12 DO
                    days := days + NumOfDays(start.year, month);
                END;
                FOR year := start.year + 1 TO end.year - 1 DO (* days between start.years and end.year *)
                    IF LeapYear(year) THEN days := days + 366; ELSE days := days + 365; END;
                END;
                FOR month := 1 TO end.month - 1 DO (* days until we reach end.month in end.year *)
                    days := days + NumOfDays(end.year, month);
                END;
            END;
            (* days in end.month until reaching end.day excluding end.day *)
            days := (days + end.day) - 1;
        END;
        (* seconds in end.day *)
        second := second + end.second + (end.minute * SecondsPerMinute) + (end.hour * SecondsPerHour);
    END;
    days := days + (second DIV SecondsPerDay); second := (second MOD SecondsPerDay);
    hours := (second DIV SecondsPerHour); second := (second MOD SecondsPerHour);
    minutes := (second DIV SecondsPerMinute); second := (second MOD SecondsPerMinute);
    seconds := second;
END TimeDifference;

(* AddYear -- Add/Subtract a number of years to/from date *)
PROCEDURE AddYears*(VAR dt: DateTime; years : INTEGER);
BEGIN
    ASSERT(IsValid(dt));
    dt.year := dt.year + years;
    ASSERT(IsValid(dt));
END AddYears;

(* AddMonths -- Add/Subtract a number of months to/from date.
This will adjust date.year if necessary *)
PROCEDURE AddMonths*(VAR dt: DateTime; months : INTEGER);
VAR years : INTEGER;
BEGIN
    ASSERT(IsValid(dt));
    years := months DIV 12;
    dt.month := dt.month + (months MOD 12);
    IF (dt.month > 12) THEN
        dt.month := dt.month - 12;
        INC(years);
    ELSIF (dt.month < 1) THEN
        dt.month := dt.month + 12;
        DEC(years);
    END;
    IF (years # 0) THEN AddYears(dt, years); END;
    ASSERT(IsValid(dt));
END AddMonths;

(* AddDays --  Add/Subtract a number of days to/from date.
This will adjust date.month and date.year if necessary *)
PROCEDURE AddDays*(VAR dt: DateTime; days : INTEGER);
VAR nofDaysLeft : INTEGER;
BEGIN
    ASSERT(IsValid(dt));
    IF (days > 0) THEN
        WHILE (days > 0) DO
            nofDaysLeft := NumOfDays(dt.year, dt.month) - dt.day;
            IF (days > nofDaysLeft) THEN
                dt.day := 1;
                AddMonths(dt, 1);
                days := days - nofDaysLeft - 1; (* -1 because we consume the first day of the next month *)
            ELSE
                dt.day := dt.day + days;
                days := 0;
            END;
        END;
    ELSIF (days < 0) THEN
        days := -days;
        WHILE (days > 0) DO
            nofDaysLeft := dt.day - 1;
            IF (days > nofDaysLeft) THEN
                dt.day := 1; (* otherwise, dt could become an invalid date if the previous month has less days than dt.day *)
                AddMonths(dt, -1);
                dt.day := NumOfDays(dt.year, dt.month);
                days := days - nofDaysLeft - 1; (* -1 because we consume the last day of the previous month *)
            ELSE
                dt.day := dt.day - days;
                days := 0;
            END;
        END;
    END;
    ASSERT(IsValid(dt));
END AddDays;

(* AddHours -- Add/Subtract a number of hours to/from date.
This will adjust date.day, date.month and date.year if necessary *)
PROCEDURE AddHours*(VAR dt: DateTime; hours : INTEGER);
VAR days : INTEGER;
BEGIN
    ASSERT(IsValid(dt));
    dt.hour := dt.hour + hours;
    days := dt.hour DIV 24;
    dt.hour := dt.hour MOD 24;
    IF (dt.hour < 0) THEN
        dt.hour := dt.hour + 24;
        DEC(days);
    END;
    IF (days # 0) THEN AddDays(dt, days); END;
    ASSERT(IsValid(dt));
END AddHours;

(* AddMinutes -- Add/Subtract a number of minutes to/from date.
This will adjust date.hour, date.day, date.month and date.year 
if necessary *)
PROCEDURE AddMinutes*(VAR dt: DateTime; minutes : INTEGER);
VAR hours : INTEGER;
BEGIN
    ASSERT(IsValid(dt));
    dt.minute := dt.minute + minutes;
    hours := dt.minute DIV 60;
    dt.minute := dt.minute MOD 60;
    IF (dt.minute < 0) THEN
        dt.minute := dt.minute + 60;
        DEC(hours);
    END;
    IF (hours # 0) THEN AddHours(dt, hours); END;
    ASSERT(IsValid(dt));
END AddMinutes;

(* AddSeconds -- Add/Subtract a number of seconds to/from date.
This will adjust date.minute, date.hour, date.day, date.month and
date.year if necessary *)
PROCEDURE AddSeconds*(VAR dt: DateTime; seconds : INTEGER);
VAR minutes : INTEGER;
BEGIN
    ASSERT(IsValid(dt));
    dt.second := dt.second + seconds;
    minutes := dt.second DIV 60;
    dt.second := dt.second MOD 60;
    IF (dt.second < 0) THEN
        dt.second := dt.second + 60;
        DEC(minutes);
    END;
    IF (minutes # 0) THEN AddMinutes(dt, minutes); END;
    ASSERT(IsValid(dt));
END AddSeconds;


(* IsDateString -- return TRUE if the ARRAY OF CHAR is 10 characters
long and is either in the form of YYYY-MM-DD or MM/DD/YYYY where
Y, M and D are digits. 
NOTE: is DOES NOT check the ranges of the digits. *)
PROCEDURE IsDateString*(inline : ARRAY OF CHAR) : BOOLEAN;
VAR 
    test : BOOLEAN; i, pos : INTEGER;
    src : ARRAY MAXSTR OF CHAR;
BEGIN
    Chars.Set(inline, src);
    Chars.TrimSpace(src);
    test := FALSE;
    IF Strings.Length(src) = 10 THEN
        pos := Strings.Pos("-", src, 0);
        IF pos > 0 THEN
            IF (src[4] = "-") & (src[7] = "-") THEN
                test := TRUE;
                FOR i := 0 TO 9 DO
                    IF (i # 4) & (i # 7) THEN
                       IF Chars.IsDigit(src[i]) = FALSE THEN
                           test := FALSE;
                       END;
                    END;
                END;
            ELSE
                test := FALSE;
            END;
        END;
        pos := Strings.Pos("/", src, 0);
        IF pos > 0 THEN
            IF (src[2] = "/") & (src[5] = "/") THEN
                test := TRUE;
                FOR i := 0 TO 9 DO
                    IF (i # 2) & (i # 5) THEN
                        IF Chars.IsDigit(src[i]) = FALSE THEN
                            test := FALSE;
                        END;
                    END;
                END;
            ELSE
                test := FALSE;
            END;
        END;
    END;
    RETURN test
END IsDateString;

(* IsTimeString -- return TRUE if the ARRAY OF CHAR has 4 to 8 
characters in the form of H:MM, HH:MM, HH:MM:SS where H, M and S
are digits. *)
PROCEDURE IsTimeString*(inline : ARRAY OF CHAR) : BOOLEAN;
VAR 
    test : BOOLEAN; 
    l : INTEGER;
    src : ARRAY MAXSTR OF CHAR;
BEGIN
    Chars.Set(inline, src);
    Chars.TrimSpace(src);
    (* remove any trailing am/pm suffixes *)
    IF Chars.EndsWith("m", src) THEN
        IF Chars.EndsWith("am", src) THEN
            Chars.TrimSuffix("am", src);
        ELSE
            Chars.TrimSuffix("pm", src);
        END;
        Chars.TrimSpace(src);
    ELSIF Chars.EndsWith("M", src) THEN
        Chars.TrimSuffix("AM", src);
        Chars.TrimSuffix("PM", src);
        Chars.TrimSpace(src);
    ELSIF Chars.EndsWith("p", src) THEN
        Chars.TrimSuffix("p", src);
        Chars.TrimSpace(src);
    ELSIF Chars.EndsWith("P", src) THEN
        Chars.TrimSuffix("P", src);
        Chars.TrimSpace(src);
    ELSIF Chars.EndsWith("a", src) THEN
        Chars.TrimSuffix("a", src);
        Chars.TrimSpace(src);
    ELSIF Chars.EndsWith("A", src) THEN
        Chars.TrimSuffix("A", src);
        Chars.TrimSpace(src);
    END;
    Strings.Extract(src, 0, 8, src);
    test := FALSE;
    l := Strings.Length(src);
    IF (l = 4) THEN
        IF Chars.IsDigit(src[0]) & (src[1] = ":") & 
            Chars.IsDigit(src[2]) & Chars.IsDigit(src[3]) THEN
            test := TRUE;
        ELSE
            test := FALSE;
        END;
    ELSIF (l = 5) THEN
        IF Chars.IsDigit(src[0]) & Chars.IsDigit(src[1]) &
            (src[2] = ":") & 
            Chars.IsDigit(src[3]) & Chars.IsDigit(src[4]) THEN
            test := TRUE;
        ELSE
            test := FALSE;
        END;
    ELSIF (l = 8) THEN
        IF Chars.IsDigit(src[0]) & Chars.IsDigit(src[1]) &
            (src[2] = ":") & 
            Chars.IsDigit(src[3]) & Chars.IsDigit(src[4]) & 
            (src[5] = ":") & 
            Chars.IsDigit(src[6]) & Chars.IsDigit(src[7]) THEN
            test := TRUE;
        ELSE
            test := FALSE;
        END;
    ELSE
        test := FALSE;
    END;
    RETURN test
END IsTimeString;

(* ParseDate -- parses a date string in YYYY-MM-DD or
MM/DD/YYYY format. *)
PROCEDURE ParseDate*(inline : ARRAY OF CHAR; VAR year, month, day : INTEGER) : BOOLEAN;
VAR src, tmp : ARRAY MAXSTR OF CHAR; ok, b : BOOLEAN;
BEGIN
    Chars.Set(inline, src);
    Chars.Clear(tmp);
    ok := FALSE;
    IF IsDateString(src) THEN
        (* FIXME: Need to allow for more than 4 digit years! *)
        IF (src[2] = "/") & (src[5] = "/") THEN
            ok := TRUE;
            Strings.Extract(src, 0, 2, tmp);
            Convert.StringToInt(tmp, month, b);
            ok := ok & b;
            Strings.Extract(src, 4, 2, tmp);
            Convert.StringToInt(tmp, day, b);
            ok := ok & b;
            Strings.Extract(src, 6, 4, tmp);
            Convert.StringToInt(tmp, year, b);
            ok := ok & b;
        ELSIF (src[4] = "-") & (src[7] = "-") THEN
            ok := TRUE;
            Strings.Extract(src, 0, 4, tmp);
            Convert.StringToInt(tmp, year, b);
            ok := ok & b;
            Strings.Extract(src, 5, 2, tmp);
            Convert.StringToInt(tmp, month, b);
            ok := ok & b;
            Strings.Extract(src, 8, 2, tmp);
            Convert.StringToInt(tmp, day, b);
            ok := ok & b;
        ELSE
            ok := FALSE;
        END;
    END;
    RETURN ok
END ParseDate;

(* ParseTime -- procedure for parsing time strings into hour,
minute, second. Returns TRUE on successful parse, FALSE otherwise *)
PROCEDURE ParseTime*(inline : ARRAY OF CHAR; VAR hour, minute, second : INTEGER) : BOOLEAN;
VAR src, tmp : ARRAY MAXSTR OF CHAR; ok : BOOLEAN; cur, pos, l : INTEGER;
BEGIN
    Chars.Set(inline, src);
    Chars.Clear(tmp);
    IF IsTimeString(src) THEN
        ok := TRUE;
        cur := 0; pos := 0;
        pos := Strings.Pos(":", src, cur);
        IF pos > 0 THEN
        (* Get Hour *)
            Strings.Extract(src, cur, pos - cur, tmp);
            Convert.StringToInt(tmp, hour, ok);
            IF ok THEN
                (* Get Minute *)
                cur := pos + 1;
                Strings.Extract(src, cur, 2, tmp);
                Convert.StringToInt(tmp, minute, ok);
                IF ok THEN
                    (* Get second, optional, default to zero *)
                    pos := Strings.Pos(":", src, cur);
                    IF pos > 0 THEN
                        cur := pos + 1;
                        Strings.Extract(src, cur, 2, tmp);
                        Convert.StringToInt(tmp, second, ok);
                        cur := cur + 2;
                    ELSE
                        second := 0;
                    END;
                    (* Get AM/PM, optional, adjust hour if PM *)
                    l := Strings.Length(src);
                    WHILE (cur < l) & Chars.IsSpace(src[cur]) DO
                        cur := cur + 1;
                    END;
                    Strings.Extract(src, cur, 2, tmp);
                    Chars.TrimSpace(tmp);
                    IF Chars.Equal(tmp, "PM") OR Chars.Equal(tmp, "pm") THEN
                        hour := hour + 12;
                    END;
                ELSE
                    ok := FALSE;
                END;
            END;
        ELSE
            ok := FALSE;
        END;
    ELSE
        ok := FALSE;
    END;
    IF ok THEN
        ok := ((hour >= 0) & (hour <= 23)) &
            ((minute >= 0) & (minute <= 59)) &
                ((second >= 0) & (second <= 59));
    END;
    RETURN ok
END ParseTime;


(* Parse accepts a date array of chars in either dates, times
or dates and times separate by spaces. Date formats supported
include YYYY-MM-DD, MM/DD/YYYY. Time formats include
H:MM, HH:MM, H:MM:SS, HH:MM:SS with 'a', 'am', 'p', 'pm' 
suffixes.  Dates and times can also be accepted as JSON 
expressions with the individual time compontents are specified 
as attributes, e.g. `{"year": 1998, "month": 12, "day": 10,
"hour": 11, "minute": 4, "second": 3}.
Parse returns TRUE on successful parse, FALSE otherwise.

BUG: Assumes a 4 digit year.
*) 
PROCEDURE Parse*(inline : ARRAY OF CHAR; VAR dt: DateTime) : BOOLEAN;
VAR src, ds, ts, tmp : ARRAY SHORTSTR OF CHAR; ok, okDate, okTime : BOOLEAN; 
    pos, year, month, day, hour, minute, second : INTEGER;
BEGIN
    dt.year := 0;
    dt.month := 0;
    dt.day := 0;
    dt.hour := 0;
    dt.minute := 0;
    dt.second := 0;
    Chars.Clear(tmp);
    Chars.Set(inline, src);
    Chars.TrimSpace(src);
    (* Split into Date and Time components *)
    pos := Strings.Pos(" ", src, 0);
    IF pos >= 0 THEN
        Strings.Extract(src, 0, pos, ds);
        pos := pos + 1;
        Strings.Extract(src, pos, Strings.Length(src) - pos, ts);
    ELSE
        Chars.Set(src, ds);
        Chars.Set(src, ts);
    END;
    ok := FALSE;
    IF IsDateString(ds) THEN
        ok := TRUE;
        okDate := ParseDate(ds, year, month, day);
        SetDate(year, month, day, dt);
        ok := ok & okDate;
    END;
    IF IsTimeString(ts) THEN
        ok := ok OR okDate;
        okTime := ParseTime(ts, hour, minute, second);
        SetTime(hour, minute, second, dt);
        ok := ok & okTime;
    END;
    RETURN ok
END Parse;

BEGIN
    Chars.Set("January", Months[0]);
    Chars.Set("February", Months[1]);
    Chars.Set("March", Months[2]);
    Chars.Set("April", Months[3]);
    Chars.Set("May", Months[4]);
    Chars.Set("June", Months[5]);
    Chars.Set("July", Months[6]);
    Chars.Set("August", Months[7]);
    Chars.Set("September", Months[8]);
    Chars.Set("October", Months[9]);
    Chars.Set("November", Months[10]);
    Chars.Set("December", Months[11]);

    Chars.Set("Sunday", Days[0]);
    Chars.Set("Monday", Days[1]);
    Chars.Set("Tuesday", Days[2]);
    Chars.Set("Wednesday", Days[3]);
    Chars.Set("Thursday", Days[4]);
    Chars.Set("Friday", Days[5]);
    Chars.Set("Saturday", Days[6]);

    DaysInMonth[0] := 31; (* January *)
    DaysInMonth[1] := 28; (* February *)
    DaysInMonth[2] := 31; (* March *)
    DaysInMonth[3] := 30; (* April *)
    DaysInMonth[4] := 31; (* May *)
    DaysInMonth[5] := 30; (* June *)
    DaysInMonth[6] := 31; (* July *)
    DaysInMonth[7] := 31; (* August *)
    DaysInMonth[8] := 30; (* September *)
    DaysInMonth[9] := 31; (* October *)
    DaysInMonth[10] := 30; (* November *)
    DaysInMonth[11] := 31; (* December *)

END Dates.