diff --git a/atproto/syntax/cmd/atp-syntax/main.go b/atproto/syntax/cmd/atp-syntax/main.go new file mode 100644 index 000000000..1f7b67d6b --- /dev/null +++ b/atproto/syntax/cmd/atp-syntax/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + + "github.com/bluesky-social/indigo/atproto/syntax" + + "github.com/urfave/cli/v2" +) + +func main() { + app := cli.App{ + Name: "atp-syntax", + Usage: "informal debugging CLI tool for atproto syntax (identifiers)", + } + app.Commands = []*cli.Command{ + &cli.Command{ + Name: "parse-tid", + Usage: "parse a TID and output timestamp", + Action: runParseTID, + }, + } + h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}) + slog.SetDefault(slog.New(h)) + app.RunAndExitOnError() +} + +func runParseTID(cctx *cli.Context) error { + s := cctx.Args().First() + if s == "" { + return fmt.Errorf("need to provide identifier as an argument") + } + + tid, err := syntax.ParseTID(s) + if err != nil { + return err + } + fmt.Printf("TID: %s\n", tid) + fmt.Printf("Time: %s\n", tid.Time()) + + return nil +} diff --git a/atproto/syntax/datetime.go b/atproto/syntax/datetime.go index 7910d6e31..22c23e2d9 100644 --- a/atproto/syntax/datetime.go +++ b/atproto/syntax/datetime.go @@ -50,6 +50,32 @@ func ParseDatetimeTime(raw string) (time.Time, error) { return d.Time(), nil } +// Similar to ParseDatetime, but more flexible about some parsing. +// +// 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. +func ParseDatetimeLenient(raw string) (Datetime, error) { + // fast path: it is a valid overall datetime + valid, err := ParseDatetime(raw) + if nil == err { + return valid, nil + } + + if strings.HasSuffix(raw, "-00:00") { + return ParseDatetime(strings.Replace(raw, "-00:00", "+00:00", 1)) + } + + // try adding timezone if it is missing + var hasTimezoneRegex = regexp.MustCompile(`^.*(([+-]\d\d:?\d\d)|[a-zA-Z])$`) + if !hasTimezoneRegex.MatchString(raw) { + withTZ, err := ParseDatetime(raw + "Z") + if nil == err { + return withTZ, nil + } + } + + return "", fmt.Errorf("Datetime could not be parsed, even leniently: %v", err) +} + // Parses the Datetime string in to a golang [time.Time]. // // 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. diff --git a/atproto/syntax/datetime_test.go b/atproto/syntax/datetime_test.go index c12e20e84..e9dda3677 100644 --- a/atproto/syntax/datetime_test.go +++ b/atproto/syntax/datetime_test.go @@ -74,6 +74,35 @@ func TestInteropDatetimeTimeInvalid(t *testing.T) { assert.NoError(scanner.Err()) } +func TestParseDatetimeLenient(t *testing.T) { + assert := assert.New(t) + + valid := []string{ + "1985-04-12T23:20:50.123Z", + "1985-04-12T23:20:50.123", + "2023-08-27T19:07:00.186173", + "1985-04-12T23:20:50.123-00:00", + "1985-04-12T23:20:50.123+00:00", + } + for _, s := range valid { + _, err := ParseDatetimeLenient(s) + assert.NoError(err) + if err != nil { + fmt.Println(s) + } + } + + invalid := []string{ + "1985-04-", + "", + "blah", + } + for _, s := range invalid { + _, err := ParseDatetimeLenient(s) + assert.Error(err) + } +} + func TestDatetimeNow(t *testing.T) { assert := assert.New(t)