diff --git a/tools/utils/iso8601.go b/tools/utils/iso8601.go new file mode 100644 index 000000000..2c4f664af --- /dev/null +++ b/tools/utils/iso8601.go @@ -0,0 +1,166 @@ +// License: GPLv3 Copyright: 2023, Kovid Goyal, + +package utils + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +var _ = fmt.Print + +func is_digit(x byte) bool { + return '0' <= x && x <= '9' +} + +// The following is copied from the Go standard library to implement date range validation logic +// equivalent to the behaviour of Go's time.Parse. + +func isLeap(year int) bool { + return year%4 == 0 && (year%100 != 0 || year%400 == 0) +} + +// daysInMonth is the number of days for non-leap years in each calendar month starting at 1 +var daysInMonth = [13]int{0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} + +func daysIn(m time.Month, year int) int { + if m == time.February && isLeap(year) { + return 29 + } + return daysInMonth[int(m)] +} + +func ISO8601Parse(raw string) (time.Time, error) { + orig := raw + raw = strings.TrimSpace(raw) + + required_number := func(num_digits int) (int, error) { + if len(raw) < num_digits { + return 0, fmt.Errorf("Insufficient digits") + } + text := raw[:num_digits] + raw = raw[num_digits:] + ans, err := strconv.ParseUint(text, 10, 32) + return int(ans), err + + } + optional_separator := func(x byte) bool { + if len(raw) > 0 && raw[0] == x { + raw = raw[1:] + } + return len(raw) > 0 && is_digit(raw[0]) + } + + errf := func(msg string) (time.Time, error) { + return time.Time{}, fmt.Errorf("Invalid ISO8601 timestamp: %#v. %s", orig, msg) + } + + optional_separator('+') + year, err := required_number(4) + if err != nil { + return errf("timestamp does not start with a 4 digit year") + } + var month int = 1 + var day int = 1 + if optional_separator('-') { + month, err = required_number(2) + if err != nil { + return errf("timestamp does not have a valid 2 digit month") + } + if optional_separator('-') { + day, err = required_number(2) + if err != nil { + return errf("timestamp does not have a valid 2 digit day") + } + } + } + + var hour, minute, second, nsec int + + if len(raw) > 0 && (raw[0] == 'T' || raw[0] == ' ') { + raw = raw[1:] + hour, err = required_number(2) + if err != nil { + return errf("timestamp does not have a valid 2 digit hour") + } + if optional_separator(':') { + minute, err = required_number(2) + if err != nil { + return errf("timestamp does not have a valid 2 digit minute") + } + if optional_separator(':') { + second, err = required_number(2) + if err != nil { + return errf("timestamp does not have a valid 2 digit second") + } + } + } + if len(raw) > 0 && (raw[0] == '.' || raw[0] == ',') { + raw = raw[1:] + num_digits := 0 + for len(raw) > num_digits && is_digit(raw[num_digits]) { + num_digits++ + } + text := raw[:num_digits] + raw = raw[num_digits:] + extra := 9 - len(text) + if extra < 0 { + text = text[:9] + } + if text != "" { + n, err := strconv.ParseUint(text, 10, 64) + if err != nil { + return errf("timestamp does not have a valid nanosecond field") + } + nsec = int(n) + for ; extra > 0; extra-- { + nsec *= 10 + } + } + } + } + switch { + case month < 1 || month > 12: + return errf("timestamp has invalid month value") + case day < 1 || day > 31 || day > daysIn(time.Month(month), year): + return errf("timestamp has invalid day value") + case hour < 0 || hour > 23: + return errf("timestamp has invalid hour value") + case minute < 0 || minute > 59: + return errf("timestamp has invalid minute value") + case second < 0 || second > 59: + return errf("timestamp has invalid second value") + } + loc := time.UTC + tzsign, tzhour, tzminute := 0, 0, 0 + + if len(raw) > 0 { + switch raw[0] { + case '+': + tzsign = 1 + case '-': + tzsign = -1 + } + } + if tzsign != 0 { + raw = raw[1:] + tzhour, err = required_number(2) + if err != nil { + return errf("timestamp has invalid timezone hour") + } + optional_separator(':') + tzminute, err = required_number(2) + if err != nil { + tzminute = 0 + } + seconds := tzhour*3600 + tzminute*60 + loc = time.FixedZone("", tzsign*seconds) + } + return time.Date(year, time.Month(month), day, hour, minute, second, nsec, loc), err +} + +func ISO8601Format(x time.Time) string { + return x.Format(time.RFC3339Nano) +} diff --git a/tools/utils/iso8601_test.go b/tools/utils/iso8601_test.go new file mode 100644 index 000000000..648dbe6c3 --- /dev/null +++ b/tools/utils/iso8601_test.go @@ -0,0 +1,40 @@ +// License: GPLv3 Copyright: 2023, Kovid Goyal, + +package utils + +import ( + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +var _ = fmt.Print + +func TestISO8601(t *testing.T) { + now := time.Now() + + tt := func(raw string, expected time.Time) { + actual, err := ISO8601Parse(raw) + if err != nil { + t.Fatalf("Parsing: %#v failed with error: %s", raw, err) + } + if diff := cmp.Diff(expected, actual); diff != "" { + t.Fatalf("Parsing: %#v failed:\n%s", raw, diff) + } + } + + tt(ISO8601Format(now), now) + tt("2023-02-08T07:24:09.551975+00:00", time.Date(2023, 2, 8, 7, 24, 9, 551975000, time.UTC)) + tt("2023-02-08T07:24:09.551975Z", time.Date(2023, 2, 8, 7, 24, 9, 551975000, time.UTC)) + tt("2023", time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)) + tt("2023-11-13", time.Date(2023, 11, 13, 0, 0, 0, 0, time.UTC)) + tt("2023-11-13 07:23", time.Date(2023, 11, 13, 7, 23, 0, 0, time.UTC)) + tt("2023-11-13 07:23:01", time.Date(2023, 11, 13, 7, 23, 1, 0, time.UTC)) + tt("2023-11-13 07:23:01.", time.Date(2023, 11, 13, 7, 23, 1, 0, time.UTC)) + tt("2023-11-13 07:23:01.0", time.Date(2023, 11, 13, 7, 23, 1, 0, time.UTC)) + tt("2023-11-13 07:23:01.1", time.Date(2023, 11, 13, 7, 23, 1, 100000000, time.UTC)) + tt("202311-13 07", time.Date(2023, 11, 13, 7, 0, 0, 0, time.UTC)) + tt("20231113 0705", time.Date(2023, 11, 13, 7, 5, 0, 0, time.UTC)) +}