1package syntax
2
3import (
4 "errors"
5 "fmt"
6 "regexp"
7 "strings"
8 "time"
9)
10
11const (
12 // Preferred atproto Datetime string syntax, for use with [time.Format].
13 //
14 // Note that *parsing* syntax is more flexible.
15 AtprotoDatetimeLayout = "2006-01-02T15:04:05.999Z"
16)
17
18// Represents the a Datetime in string format, as would pass Lexicon syntax validation: the intersection of RFC-3339 and ISO-8601 syntax.
19//
20// Always use [ParseDatetime] instead of wrapping strings directly, especially when working with network input.
21//
22// Syntax is specified at: https://atproto.com/specs/lexicon#datetime
23type Datetime string
24
25var datetimeRegex = regexp.MustCompile(`^[0-9]{4}-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-6][0-9]:[0-6][0-9](.[0-9]{1,20})?(Z|([+-][0-2][0-9]:[0-5][0-9]))$`)
26
27func ParseDatetime(raw string) (Datetime, error) {
28 if raw == "" {
29 return "", errors.New("expected datetime, got empty string")
30 }
31 if len(raw) > 64 {
32 return "", errors.New("Datetime too long (max 64 chars)")
33 }
34
35 if !datetimeRegex.MatchString(raw) {
36 return "", errors.New("Datetime syntax didn't validate via regex")
37 }
38 if strings.HasSuffix(raw, "-00:00") {
39 return "", errors.New("Datetime can't use '-00:00' for UTC timezone, must use '+00:00', per ISO-8601")
40 }
41 // ensure that the datetime actually parses using golang time lib
42 _, err := time.Parse(time.RFC3339Nano, raw)
43 if err != nil {
44 return "", err
45 }
46 return Datetime(raw), nil
47}
48
49// Validates and converts a string to a golang [time.Time] in a single step.
50func ParseDatetimeTime(raw string) (time.Time, error) {
51 d, err := ParseDatetime(raw)
52 if err != nil {
53 var zero time.Time
54 return zero, err
55 }
56 return d.Time(), nil
57}
58
59// Similar to ParseDatetime, but more flexible about some parsing.
60//
61// Note that this may mutate the internal string, so a round-trip will fail. This is intended for working with legacy/broken records, not to be used in an ongoing way.
62var hasTimezoneRegex = regexp.MustCompile(`^.*(([+-]\d\d:?\d\d)|[a-zA-Z])$`)
63
64func ParseDatetimeLenient(raw string) (Datetime, error) {
65 // fast path: it is a valid overall datetime
66 valid, err := ParseDatetime(raw)
67 if nil == err {
68 return valid, nil
69 }
70
71 if strings.HasSuffix(raw, "-00:00") {
72 return ParseDatetime(strings.Replace(raw, "-00:00", "+00:00", 1))
73 }
74 if strings.HasSuffix(raw, "-0000") {
75 return ParseDatetime(strings.Replace(raw, "-0000", "+00:00", 1))
76 }
77 if strings.HasSuffix(raw, "+0000") {
78 return ParseDatetime(strings.Replace(raw, "+0000", "+00:00", 1))
79 }
80
81 // try adding timezone if it is missing
82 if !hasTimezoneRegex.MatchString(raw) {
83 withTZ, err := ParseDatetime(raw + "Z")
84 if nil == err {
85 return withTZ, nil
86 }
87 }
88
89 return "", fmt.Errorf("Datetime could not be parsed, even leniently: %v", err)
90}
91
92// Parses the Datetime string in to a golang [time.Time].
93//
94// This method assumes that [ParseDatetime] was used to create the Datetime, which already verified parsing, and thus that [time.Parse] will always succeed. In the event of an error, zero/nil will be returned.
95func (d Datetime) Time() time.Time {
96 var zero time.Time
97 ret, err := time.Parse(time.RFC3339Nano, d.String())
98 if err != nil {
99 return zero
100 }
101 return ret
102}
103
104// Creates a new valid Datetime string matching the current time, in preferred syntax.
105func DatetimeNow() Datetime {
106 t := time.Now().UTC()
107 return Datetime(t.Format(AtprotoDatetimeLayout))
108}
109
110func (d Datetime) String() string {
111 return string(d)
112}
113
114func (d Datetime) MarshalText() ([]byte, error) {
115 return []byte(d.String()), nil
116}
117
118func (d *Datetime) UnmarshalText(text []byte) error {
119 datetime, err := ParseDatetime(string(text))
120 if err != nil {
121 return err
122 }
123 *d = datetime
124 return nil
125}