Code to parse ISO8601 timestamps at least semi-robustly
This commit is contained in:
parent
64cb9c9542
commit
c113ad6f56
166
tools/utils/iso8601.go
Normal file
166
tools/utils/iso8601.go
Normal file
@ -0,0 +1,166 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
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)
|
||||
}
|
||||
40
tools/utils/iso8601_test.go
Normal file
40
tools/utils/iso8601_test.go
Normal file
@ -0,0 +1,40 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
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))
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user