Compare commits

..

No commits in common. "e74906e0c46eaf7c5cb422b43d4c778f323f410c" and "35270f72ca61feac025776dc4ff0dea68eb9e1d0" have entirely different histories.

11 changed files with 13 additions and 648 deletions

View file

@ -13,7 +13,6 @@ build.wasm:
mkdir target/wasm
GOOS=js GOARCH=wasm go build -o target/wasm/clocks.wasm ./cmds/clocks
GOOS=js GOARCH=wasm go build -o target/wasm/gotemplate.wasm ./cmds/gotemplate
GOOS=js GOARCH=wasm go build -o target/wasm/timestamps.wasm ./cmds/timestamps
cp $(GOROOT)/lib/wasm/wasm_exec.js target/wasm/.
.Phony: build.site

View file

@ -1,119 +0,0 @@
package main
import (
"errors"
"time"
)
type TimeComponent struct {
TS *string `parser:"@Time"`
}
func (t TimeComponent) Time(loc *time.Location) (time.Time, error) {
return time.ParseInLocation("15:04:05", *t.TS, loc)
}
func (t TimeComponent) RequiresRefreshing() bool {
return false
}
type TimeInZoneComponent struct {
TS *string `parser:"@Time"`
InUTC bool `parser:"@Z?"`
}
func (t TimeInZoneComponent) Time() (time.Time, error) {
loc := time.Local
if t.InUTC {
loc = time.UTC
}
return time.ParseInLocation("15:04:05", *t.TS, loc)
}
func (t TimeInZoneComponent) RequiresRefreshing() bool {
return false
}
type DateWithTimeComponents struct {
Date *string `parser:"@Date"`
MaybeTime *TimeComponent `parser:"(T? @@)?"`
InUTC bool `parser:"(@Z)?"`
}
func (d DateWithTimeComponents) Time() (time.Time, error) {
loc := time.Local
if d.InUTC {
loc = time.UTC
}
t, err := time.ParseInLocation("2006-01-02", *d.Date, loc)
if err != nil {
return time.Time{}, err
}
if d.MaybeTime != nil {
tt, err := d.MaybeTime.Time(loc)
if err != nil {
return time.Time{}, err
}
t = time.Date(t.Year(), t.Month(), t.Day(), tt.Hour(), tt.Minute(), tt.Second(), 0, loc)
}
return t, nil
}
type Timestamp struct {
DT *DateWithTimeComponents `parser:"@@"`
TimeToday *TimeInZoneComponent `parser:" | @@"`
Int *int `parser:" | @Int"`
}
func (t Timestamp) Time() (time.Time, error) {
switch {
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
}

View file

@ -1,37 +0,0 @@
package main
import (
"time"
"github.com/alecthomas/participle/v2"
"github.com/alecthomas/participle/v2/lexer"
)
var basicLexer = lexer.MustSimple([]lexer.SimpleRule{
{"Identifier", `[a-z][a-zA-Z0-9_]*`},
{"Date", `\d{4}-\d{2}-\d{2}`},
{"Time", `\d{2}:\d{2}:\d{2}`},
{"Int", `\d+`},
{"T", `T`},
{"Z", `Z`},
{"Whitespace", `[ ]+`},
})
var parser = participle.MustBuild[Atom](
participle.Lexer(basicLexer),
participle.Elide("Whitespace"),
participle.UseLookahead(2),
)
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
}

View file

@ -1,113 +0,0 @@
package main
import (
"testing"
"time"
)
func TestParse(t *testing.T) {
// Reference time: 2025-11-19T11:07:13Z
// Unix: 1763550433
refTime := time.Date(2025, 11, 19, 11, 7, 13, 0, time.UTC)
tests := []struct {
input string
expected func() time.Time
desc string
}{
{
input: "1763550433",
expected: func() time.Time {
return refTime
},
desc: "Unix timestamp",
},
{
input: "2025-11-19T11:07:13Z",
expected: func() time.Time {
return refTime
},
desc: "ISO 8601 UTC",
},
{
input: "2025-11-19T11:07:13",
expected: func() time.Time {
return time.Date(2025, 11, 19, 11, 7, 13, 0, time.Local)
},
desc: "ISO 8601 Local",
},
{
input: "2025-11-19 11:07:13Z",
expected: func() time.Time {
return refTime
},
desc: "Date Space Time Z",
},
{
input: "2025-11-19",
expected: func() time.Time {
return time.Date(2025, 11, 19, 0, 0, 0, 0, time.Local)
},
desc: "Date only Local",
},
{
input: "2025-11-19Z",
expected: func() time.Time {
return time.Date(2025, 11, 19, 0, 0, 0, 0, time.UTC)
},
desc: "Date only UTC",
},
{
input: "11:07:13",
expected: func() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 11, 7, 13, 0, time.Local)
},
desc: "Time only Local",
},
{
input: "11:07:13Z",
expected: func() time.Time {
now := time.Now().In(time.UTC)
return time.Date(now.Year(), now.Month(), now.Day(), 11, 7, 13, 0, time.UTC)
},
desc: "Time only UTC",
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
res, err := Parse(tc.input)
if err != nil {
t.Fatalf("Parse(%q) error: %v", tc.input, err)
}
got, err := res.Time()
if err != nil {
t.Fatalf("res.Time() error: %v", err)
}
want := tc.expected()
// For "Time only" tests, there might be a slight race condition if the day changes between test execution and expected value generation.
// But practically it's fine.
if !got.Truncate(time.Second).Equal(want) {
t.Errorf("Parse(%q) = %v, want %v", tc.input, got, want)
}
})
}
t.Run("now", func(t *testing.T) {
res, err := Parse("now")
if err != nil {
t.Fatalf("Parse('now') error: %v", err)
}
got, err := res.Time()
if err != nil {
t.Fatalf("res.Time() error: %v", err)
}
if time.Since(got) > time.Second {
t.Errorf("Parse('now') = %v, want close to now", got)
}
})
}

View file

@ -1,224 +0,0 @@
//go:build js
package main
import (
"fmt"
"strings"
"syscall/js"
"time"
)
func main() {
c := make(chan struct{}, 0)
doc := js.Global().Get("document")
inputArea := doc.Call("getElementById", "input")
outputArea := doc.Call("getElementById", "output")
operationSelect := doc.Call("getElementById", "operation")
timezoneRadios := doc.Call("getElementsByName", "timezone")
var updateOutput js.Func
updateOutput = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
operation := operationSelect.Get("value").String()
timezone := "utc"
for i := 0; i < timezoneRadios.Length(); i++ {
if timezoneRadios.Index(i).Get("checked").Bool() {
timezone = timezoneRadios.Index(i).Get("value").String()
break
}
}
lines := strings.Split(inputArea.Get("value").String(), "\n")
var processedLines []string
for _, line := range lines {
processedLines = append(processedLines, processLine(line, operation, timezone))
}
outputArea.Set("value", strings.Join(processedLines, "\n"))
return nil
})
inputArea.Call("addEventListener", "input", updateOutput)
operationSelect.Call("addEventListener", "change", updateOutput)
for i := 0; i < timezoneRadios.Length(); i++ {
timezoneRadios.Index(i).Call("addEventListener", "change", updateOutput)
}
// Initial sync
updateOutput.Invoke()
<-c
}
func processLine(line, operation, timezone string) string {
trimmedLine := strings.TrimSpace(line)
if trimmedLine == "" || strings.HasPrefix(trimmedLine, "#") {
return line
}
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":
return fmt.Sprintf("%d", t.Unix())
case "unix_micro":
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 operation == "to_utc" {
// If input has no timezone (t.Location() is UTC but offset is 0 and it might have been local),
// time.Parse("2006-01-02T15:04:05", ...) returns UTC time with that face value.
// 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.
return t.UTC().Format("2006-01-02T15:04:05.000Z07:00")
} else {
// 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.
jsDate := js.Global().Get("Date").New(trimmedLine)
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"
}
if timezone == "utc" {
return jsDate.Call("toISOString").String()
} else {
// Local
// We want to display in local time.
// JS `toISOString` is always UTC.
// We can construct a local ISO-like string.
// Or use `toLocaleString`? But requirement says "ISO 8601".
// Let's do the offset trick.
offset := jsDate.Call("getTimezoneOffset").Int() // minutes
// Subtract offset (which is positive for West, negative for East usually? Wait. MDN: UTC - Local in minutes. So -480 for SG.)
// Actually MDN: "The time-zone offset is the difference, in minutes, between UTC and local time. Note that this means that the offset is positive if the local timezone is behind UTC and negative if it is ahead."
// 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()
return strings.TrimSuffix(iso, "Z")
}
}
// Unix / Unix Micro
// t is the time.
if timezone == "utc" {
return t.UTC().Format("2006-01-02T15:04:05.000Z07:00")
} else {
// Local
// We need browser's local timezone.
// Go's `time.Local` is not browser's local.
// Use JS.
jsDate := js.Global().Get("Date").New(t.UnixMilli())
offset := jsDate.Call("getTimezoneOffset").Int()
newTime := jsDate.Call("getTime").Float() - (float64(offset) * 60 * 1000)
localDate := js.Global().Get("Date").New(newTime)
iso := localDate.Call("toISOString").String()
return strings.TrimSuffix(iso, "Z")
}
}

2
go.mod
View file

@ -1,5 +1,3 @@
module github.com/lmika/webtools
go 1.24.3
require github.com/alecthomas/participle/v2 v2.1.4 // indirect

2
go.sum
View file

@ -1,2 +0,0 @@
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=

View file

@ -1,21 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Tools</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
>
</head>
<body class="container">
<header>
<header>
<hgroup>
<h1>Tools</h1>
<p>Collection of online tools</p>
</hgroup>
</header>
</header>
<main>
<ul>
<li><a href="/clocks/">Clocks</a></li>
@ -23,9 +24,7 @@
<li><a href="/gotemplate/">Go Template Playground</a></li>
<li><a href="/gradient-bands/">Gradient Bands</a></li>
<li><a href="/2lcc/">Two-letter Country Codes</a></li>
<li><a href="/timestamps/">Timestamp Converter</a></li>
</ul>
</main>
</body>
</html>

View file

@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timestamp Converter</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Timestamp Converter</h1>
<div class="controls">
<select id="operation">
<option value="iso_8601">ISO-8601</option>
<option value="unix">Unix</option>
<option value="unix_micro">Unix Micro</option>
</select>
<div class="timezone-selector">
<label>
<input type="radio" name="timezone" value="utc" checked> UTC
</label>
<label>
<input type="radio" name="timezone" value="local"> Local
</label>
</div>
</div>
<div class="editor-pane">
<textarea id="input" placeholder="Enter timestamps here (one per line)..." spellcheck="false"></textarea>
<textarea id="output" readonly placeholder="Results will appear here..." spellcheck="false"></textarea>
</div>
</div>
<script src="main.js" type="module"></script>
</body>
</html>

View file

@ -1,8 +0,0 @@
import "/wasm/wasm_exec.js";
const go = new Go();
WebAssembly.instantiateStreaming(fetch("/wasm/timestamps.wasm"), go.importObject)
.then((result) => {
go.run(result.instance);
});

View file

@ -1,91 +0,0 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.container {
display: flex;
flex-direction: column;
height: 100%;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
h1 {
margin-top: 0;
margin-bottom: 20px;
font-size: 24px;
color: #333;
}
.controls {
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 20px;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
select {
padding: 8px 12px;
font-size: 14px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
}
.timezone-selector {
display: flex;
gap: 15px;
}
.timezone-selector label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 14px;
color: #555;
}
.editor-pane {
display: flex;
gap: 20px;
flex: 1;
min-height: 0; /* Important for nested flex scrolling */
}
textarea {
flex: 1;
padding: 15px;
border: 1px solid #ddd;
border-radius: 8px;
resize: none;
font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Mono", "Droid Sans Mono", "Source Code Pro", monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre;
overflow-y: auto;
background-color: white;
}
textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0,123,255,0.1);
}
#output {
background-color: #f8f9fa;
color: #333;
}