From 5c89c44ff7ee76d2f1c0544cd65876a40690afb0 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 19 Nov 2025 22:05:58 +1100 Subject: [PATCH] Switched to expression-based timestamps --- cmds/timestamps/ast.go | 105 +++++++++++++++++++++++++++++++ cmds/timestamps/input.go | 35 +++++++++++ cmds/timestamps/main.go | 112 +++++++++++++++++++++------------- go.mod | 2 + go.sum | 2 + site/timestamps/index.html | 7 +-- site/timestamps/test_logic.js | 103 ------------------------------- 7 files changed, 215 insertions(+), 151 deletions(-) create mode 100644 cmds/timestamps/ast.go create mode 100644 cmds/timestamps/input.go create mode 100644 go.sum delete mode 100644 site/timestamps/test_logic.js diff --git a/cmds/timestamps/ast.go b/cmds/timestamps/ast.go new file mode 100644 index 0000000..43679fb --- /dev/null +++ b/cmds/timestamps/ast.go @@ -0,0 +1,105 @@ +package main + +import ( + "errors" + "strings" + "time" +) + +type TimeComponent struct { + TS *string `parser:"@Time"` +} + +func (t TimeComponent) Time() (time.Time, error) { + if strings.HasSuffix(*t.TS, "Z") { + return time.ParseInLocation("15:04:05Z", *t.TS, time.UTC) + } + return time.ParseInLocation("15:04:05", *t.TS, time.Local) +} + +func (t TimeComponent) RequiresRefreshing() bool { + return false +} + +type DateWithTimeComponents struct { + Date *string `parser:"@Date"` + MaybeTime *TimeComponent `parser:" @@?"` +} + +func (d DateWithTimeComponents) Time() (time.Time, error) { + t, err := time.Parse("2006-01-02", *d.Date) + if err != nil { + return time.Time{}, err + } + if d.MaybeTime != nil { + tt, err := d.MaybeTime.Time() + if err != nil { + return time.Time{}, err + } + t = t.Add(tt.Sub(tt.Truncate(24 * time.Hour))) + } + return t, nil +} + +type Timestamp struct { + Full *string `parser:"@Timestamp"` + DT *DateWithTimeComponents `parser:"| @@"` + TimeToday *TimeComponent `parser:" | @@"` + Int *int `parser:" | @Int"` +} + +func (t Timestamp) Time() (time.Time, error) { + switch { + case t.Full != nil: + if strings.HasSuffix(*t.Full, "Z") { + return time.ParseInLocation("2006-01-02T15:04:05Z", *t.Full, time.UTC) + } + return time.ParseInLocation("2006-01-02T15:04:05", *t.Full, time.Local) + case t.DT != nil: + return t.DT.Time() + case t.TimeToday != nil: + tt, err := t.TimeToday.Time() + if err != nil { + return time.Time{}, err + } + today := time.Now().In(tt.Location()) + return time.Date(today.Year(), today.Month(), today.Day(), tt.Hour(), tt.Minute(), tt.Second(), tt.Nanosecond(), tt.Location()), nil + } + return time.Unix(int64(*t.Int), 0), nil +} + +func (t Timestamp) RequiresRefreshing() bool { + return false +} + +type Atom struct { + Timestamp *Timestamp `parser:"@@"` + Identifier *string `parser:" | @Identifier"` +} + +func (a Atom) Time() (time.Time, error) { + switch { + case a.Timestamp != nil: + return a.Timestamp.Time() + case a.Identifier != nil: + if *a.Identifier == "now" { + return time.Now(), nil + } + + return time.Time{}, errors.New("unknown identifier") + } + return time.Time{}, errors.New("unhandled case") +} + +func (a Atom) RequiresRefreshing() bool { + switch { + case a.Timestamp != nil: + return a.Timestamp.RequiresRefreshing() + case a.Identifier != nil: + if *a.Identifier == "now" { + return true + } + return false + } + return false +} diff --git a/cmds/timestamps/input.go b/cmds/timestamps/input.go new file mode 100644 index 0000000..a569805 --- /dev/null +++ b/cmds/timestamps/input.go @@ -0,0 +1,35 @@ +package main + +import ( + "time" + + "github.com/alecthomas/participle/v2" + "github.com/alecthomas/participle/v2/lexer" +) + +var basicLexer = lexer.MustSimple([]lexer.SimpleRule{ + {"Identifier", `[a-zA-Z_][a-zA-Z0-9_]*`}, + {"Date", `\d{4}-\d{2}-\d{2}`}, + {"Timestamp", `\d{4}-\d{2}-\d{2}T?\d{2}:\d{2}:\d{2}Z?`}, + {"Time", `\d{2}:\d{2}:\d{2}Z?`}, + {"Int", `\d+`}, + {"Whitespace", `[ ]+`}, +}) + +var parser = participle.MustBuild[Atom]( + participle.Lexer(basicLexer), + participle.Elide("Whitespace"), +) + +func Parse(expr string) (TimeResult, error) { + res, err := parser.ParseString("expr", expr) + if err != nil { + return nil, err + } + return res, nil +} + +type TimeResult interface { + Time() (time.Time, error) + RequiresRefreshing() bool +} diff --git a/cmds/timestamps/main.go b/cmds/timestamps/main.go index 006bb59..fdff425 100644 --- a/cmds/timestamps/main.go +++ b/cmds/timestamps/main.go @@ -3,7 +3,7 @@ package main import ( - "strconv" + "fmt" "strings" "syscall/js" "time" @@ -59,43 +59,67 @@ func processLine(line, operation, timezone string) string { return line } - var t time.Time - var err error + res, err := Parse(trimmedLine) + if err != nil { + return err.Error() + } + + t, err := res.Time() + if err != nil { + return err.Error() + } + + if timezone == "utc" { + t = t.In(time.UTC) + } else { + t = t.In(time.Local) + } switch operation { + case "iso_8601": + return t.Format(time.RFC3339) case "unix": - sec, err := strconv.ParseInt(trimmedLine, 10, 64) - if err != nil { - return "Invalid Date" - } - t = time.Unix(sec, 0) + return fmt.Sprintf("%d", t.Unix()) case "unix_micro": - ms, err := strconv.ParseInt(trimmedLine, 10, 64) - if err != nil { - return "Invalid Date" - } - t = time.Unix(0, ms*1000000) - case "to_utc", "from_utc": - // Go's time.Parse is strict. We need to handle ISO 8601 flexible formats. - // Try standard layouts. - layouts := []string{ - time.RFC3339, - time.RFC3339Nano, - "2006-01-02T15:04:05", - "2006-01-02T15:04:05.999", - } - parsed := false - for _, layout := range layouts { - t, err = time.Parse(layout, trimmedLine) - if err == nil { - parsed = true - break + return fmt.Sprintf("%d", t.UnixNano()/1000) + } + + /* + switch operation { + case "unix": + sec, err := strconv.ParseInt(trimmedLine, 10, 64) + if err != nil { + return "Invalid Date" + } + t = time.Unix(sec, 0) + case "unix_micro": + ms, err := strconv.ParseInt(trimmedLine, 10, 64) + if err != nil { + return "Invalid Date" + } + t = time.Unix(0, ms*1000000) + case "to_utc", "from_utc": + // Go's time.Parse is strict. We need to handle ISO 8601 flexible formats. + // Try standard layouts. + layouts := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02T15:04:05", + "2006-01-02T15:04:05.999", + } + parsed := false + for _, layout := range layouts { + t, err = time.Parse(layout, trimmedLine) + if err == nil { + parsed = true + break + } + } + if !parsed { + return "Invalid Date" } } - if !parsed { - return "Invalid Date" - } - } + */ if operation == "to_utc" { // If input has no timezone (t.Location() is UTC but offset is 0 and it might have been local), @@ -103,12 +127,12 @@ func processLine(line, operation, timezone string) string { // If we want to treat it as "Local" (browser local), we don't know what browser local is easily in Go WASM without JS help. // However, the JS implementation relied on `new Date()` which uses browser local. // In Go WASM, `time.Local` is usually UTC unless configured. - + // Let's use JS Date for parsing to be consistent with previous behavior and browser's local time? // Or we can try to implement it purely in Go. // If we want to support "Local" interpretation of a string like "2023-01-01T10:00:00", we need the local offset. // We can get the local timezone offset from JS. - + if timezone == "utc" { // If user selected UTC, we treat the input as UTC (which time.Parse does by default for no-offset strings) // and output UTC. @@ -117,35 +141,35 @@ func processLine(line, operation, timezone string) string { // If user selected Local, we treat input as Local? // "To UTC": converts the input in ISO 8601 in the selected timezone into UTC. // If I select "Local" and input "10:00", I mean "10:00 Local". I want "XX:XX UTC". - + // If the input string HAS an offset, we respect it. // If it doesn't, we assume it's in `timezone`. - + // Check if input has offset. // time.Parse parses into UTC if no offset found. // If we want to interpret "10:00" as Local, we need to adjust. - + // Let's stick to a simpler approach: Use JS to parse if possible, or just use Go. // Using JS from Go is easy. - + jsDate := js.Global().Get("Date").New(trimmedLine) if jsDate.Call("toString").String() == "Invalid Date" { return "Invalid Date" } - + // If "to_utc", we just want to show the UTC string. // But wait, `new Date("2023-01-01T10:00:00")` is Local in browser. // `new Date("2023-01-01T10:00:00Z")` is UTC. - + if timezone == "utc" && !strings.HasSuffix(strings.ToUpper(trimmedLine), "Z") && !strings.ContainsAny(trimmedLine, "+-") { // Force UTC interpretation jsDate = js.Global().Get("Date").New(trimmedLine + "Z") } - + return jsDate.Call("toISOString").String() } } - + if operation == "from_utc" { // Input assumed UTC. // "2023-01-01T10:00:00" -> Treat as UTC. @@ -153,7 +177,7 @@ func processLine(line, operation, timezone string) string { if !strings.HasSuffix(strings.ToUpper(trimmedLine), "Z") && !strings.ContainsAny(trimmedLine, "+-") { jsDate = js.Global().Get("Date").New(trimmedLine + "Z") } - + if jsDate.Call("toString").String() == "Invalid Date" { return "Invalid Date" } @@ -173,7 +197,7 @@ func processLine(line, operation, timezone string) string { // So SG (UTC+8) is -480. // To get local time in UTC components: add (-offset). // timestamp - (offset * 60 * 1000) - + newTime := jsDate.Call("getTime").Float() - (float64(offset) * 60 * 1000) localDate := js.Global().Get("Date").New(newTime) iso := localDate.Call("toISOString").String() diff --git a/go.mod b/go.mod index 71cbe1b..7f220ff 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/lmika/webtools go 1.24.3 + +require github.com/alecthomas/participle/v2 v2.1.4 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bff288a --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U= +github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI= diff --git a/site/timestamps/index.html b/site/timestamps/index.html index 2f6b650..fad08d0 100644 --- a/site/timestamps/index.html +++ b/site/timestamps/index.html @@ -13,10 +13,9 @@

Timestamp Converter