Big change
- Reorganised request template handling and error handling - Added Content-Type response - Implemented templating data - Break out routermain
parent
b18b3c0035
commit
5e9d1ab6cb
2
Makefile
2
Makefile
|
@ -16,7 +16,7 @@ clean:
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: datetime
|
build: datetime
|
||||||
|
|
||||||
datetime: *.go
|
datetime: *.go data/*.go
|
||||||
$(GO) build -tags "$(TAGS)" -v -o datetime
|
$(GO) build -tags "$(TAGS)" -v -o datetime
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
55
app.go
55
app.go
|
@ -1,6 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
@ -8,6 +9,9 @@ import (
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrNoTemplate is returned when a template was not found on the server
|
||||||
|
var ErrNoTemplate = errors.New("missing template")
|
||||||
|
|
||||||
// Datetime is the main application server
|
// Datetime is the main application server
|
||||||
type Datetime struct {
|
type Datetime struct {
|
||||||
*http.ServeMux
|
*http.ServeMux
|
||||||
|
@ -19,7 +23,7 @@ type Datetime struct {
|
||||||
// like templates and data exist
|
// like templates and data exist
|
||||||
func NewDatetime() (*Datetime, error) {
|
func NewDatetime() (*Datetime, error) {
|
||||||
// Data
|
// Data
|
||||||
tmpl, err := template.ParseGlob("templates/*")
|
tmpl, err := template.New("templates").Funcs(templateFuncs).ParseGlob("templates/*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -42,6 +46,11 @@ func NewDatetime() (*Datetime, error) {
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type appRequest struct {
|
||||||
|
App Datetime
|
||||||
|
Req Request
|
||||||
|
}
|
||||||
|
|
||||||
// index handles all incoming page requests
|
// index handles all incoming page requests
|
||||||
func (app Datetime) index(w http.ResponseWriter, req *http.Request) {
|
func (app Datetime) index(w http.ResponseWriter, req *http.Request) {
|
||||||
var err error
|
var err error
|
||||||
|
@ -51,45 +60,29 @@ func (app Datetime) index(w http.ResponseWriter, req *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accept := req.Header.Get("Accept")
|
tmpl := app.loadTemplate("index", w, req)
|
||||||
tmpl, acceptable := app.chooseTemplate(accept)
|
|
||||||
if !acceptable {
|
|
||||||
w.WriteHeader(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
l.Error("unable to find template", zap.String("accept", accept))
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Method == http.MethodHead {
|
if req.Method == http.MethodHead {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
l.Debug("", zap.Reflect("url", req.URL))
|
request := Request{}
|
||||||
err = tmpl.Execute(w, nil)
|
if req.URL.Path != "/" {
|
||||||
|
request, err = ParseRequest(req.URL)
|
||||||
|
if err != nil {
|
||||||
|
l.Debug("parse failed", zap.Error(err))
|
||||||
|
app.error(HTTPError{http.StatusBadRequest, err}, w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Debug("rendering template", zap.Reflect("request", request))
|
||||||
|
err = tmpl.Execute(w, appRequest{app, request})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Error("templating failed", zap.Error(err))
|
l.Error("templating failed", zap.Error(err))
|
||||||
w.WriteHeader(http.StatusInternalServerError) // Usually this will fail
|
app.templateError(HTTPError{http.StatusInternalServerError, err}, w, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// chooseTemplate returns a template based on the accepted mime types from the
|
|
||||||
// client, and if a template cannot be found it returns a nil template.
|
|
||||||
func (app Datetime) chooseTemplate(accept string) (t *template.Template, acceptable bool) {
|
|
||||||
responseType := chooseResponseType(accept)
|
|
||||||
templateName := ""
|
|
||||||
switch responseType {
|
|
||||||
case responsePlain:
|
|
||||||
templateName = "index.txt"
|
|
||||||
case responseHTML:
|
|
||||||
templateName = "index.html"
|
|
||||||
case responseAny:
|
|
||||||
templateName = "index.txt"
|
|
||||||
case responseUnknown:
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return app.tmpl.Lookup(templateName), true
|
|
||||||
}
|
|
||||||
|
|
22
app_test.go
22
app_test.go
|
@ -7,27 +7,31 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestChooseTemplate(t *testing.T) {
|
func TestChooseTemplate(t *testing.T) {
|
||||||
tmpl, err := template.ParseGlob("templates/*")
|
tmpl, err := template.New("templates").Funcs(templateFuncs).ParseGlob("templates/*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
app := &Datetime{tmpl: tmpl}
|
app := &Datetime{tmpl: tmpl}
|
||||||
|
|
||||||
type chooseTest struct {
|
type chooseTest struct {
|
||||||
accept string
|
accept string
|
||||||
acceptable bool
|
acceptable bool
|
||||||
template string
|
contentType string
|
||||||
|
template string
|
||||||
}
|
}
|
||||||
tests := []chooseTest{
|
tests := []chooseTest{
|
||||||
{"text/html", true, "index.html"},
|
{"text/html", true, "text/html", "index.html"},
|
||||||
{"text/html;q=0.9,text/plain", true, "index.txt"},
|
{"text/html;q=0.9,text/plain", true, "text/plain", "index.txt"},
|
||||||
{"image/png", false, ""},
|
{"image/png", false, "", ""},
|
||||||
{"*/*", true, "index.txt"},
|
{"*/*", true, "text/plain", "index.txt"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
tmpl, acceptable := app.chooseTemplate(test.accept)
|
tmpl, contentType, acceptable := app.chooseTemplate(test.accept, "index")
|
||||||
fn := fmt.Sprintf("chooseTemplate(\"%s\")", test.accept)
|
fn := fmt.Sprintf("chooseTemplate(\"%s\")", test.accept)
|
||||||
|
if contentType != test.contentType {
|
||||||
|
t.Errorf("%s; contentType = %v; wanted %v", fn, contentType, test.contentType)
|
||||||
|
}
|
||||||
if acceptable != test.acceptable {
|
if acceptable != test.acceptable {
|
||||||
t.Errorf("%s; acceptable = %v; wanted %v", fn, acceptable, test.acceptable)
|
t.Errorf("%s; acceptable = %v; wanted %v", fn, acceptable, test.acceptable)
|
||||||
}
|
}
|
||||||
|
|
13
data/data.go
13
data/data.go
|
@ -3,6 +3,7 @@ package data
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReadCities opens the file "data/cities.json" and reads it into a map
|
// ReadCities opens the file "data/cities.json" and reads it into a map
|
||||||
|
@ -20,3 +21,15 @@ func ReadCities() (map[string]*City, error) {
|
||||||
|
|
||||||
return cities, nil
|
return cities, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extendName(names ...string) string {
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullName returns a fully qualified human readable name
|
||||||
|
func (c City) FullName() string {
|
||||||
|
if len(c.Admin1.Name) > 0 {
|
||||||
|
return extendName(c.Name, c.Admin1.Name, c.Country.Name)
|
||||||
|
}
|
||||||
|
return extendName(c.Name, c.Country.Name)
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
package data
|
package data
|
||||||
|
|
||||||
// city represents a city that belongs inside an administrative division level 1
|
// City represents a city that belongs inside an administrative division level 1
|
||||||
// and a country
|
// and a country
|
||||||
type City struct {
|
type City struct {
|
||||||
// Ref is the ASCII name of the city
|
// Ref is the ASCII name of the city
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPError is an error that can be rendered into a HTML page to follow HTTP
|
||||||
|
// status semantics
|
||||||
|
type HTTPError struct {
|
||||||
|
Status int
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app Datetime) error(httpErr HTTPError, w http.ResponseWriter, req *http.Request) {
|
||||||
|
tmpl := app.loadTemplate("error", w, req)
|
||||||
|
if tmpl == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(httpErr.Status)
|
||||||
|
if req.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tmpl.Execute(w, httpErr)
|
||||||
|
if err != nil {
|
||||||
|
l.Error("template failed", zap.Error(err))
|
||||||
|
app.templateError(HTTPError{http.StatusInternalServerError, err}, w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app Datetime) simpleError(httpErr HTTPError, w http.ResponseWriter, req *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
|
||||||
|
w.WriteHeader(httpErr.Status)
|
||||||
|
if req.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpErr.Error != nil {
|
||||||
|
w.Write([]byte(http.StatusText(httpErr.Status) + ": " + httpErr.Error.Error()))
|
||||||
|
} else {
|
||||||
|
w.Write([]byte(http.StatusText(httpErr.Status)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app Datetime) templateError(httpErr HTTPError, w http.ResponseWriter, req *http.Request) {
|
||||||
|
// Sadly, we probably already sent out the header
|
||||||
|
//w.Header().Set("Content-Type", "text/plain")
|
||||||
|
//w.WriteHeader(httpErr.Status)
|
||||||
|
|
||||||
|
if httpErr.Error != nil {
|
||||||
|
w.Write([]byte("\n" + http.StatusText(httpErr.Status) + ": " + httpErr.Error.Error()))
|
||||||
|
} else {
|
||||||
|
w.Write([]byte("\n" + http.StatusText(httpErr.Status)))
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,8 +4,11 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
)
|
)
|
||||||
|
|
||||||
func zapConfig() zap.Config {
|
func zapConfig() zap.Config {
|
||||||
return zap.NewDevelopmentConfig()
|
dev := zap.NewDevelopmentConfig()
|
||||||
|
dev.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||||
|
return dev
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,7 @@ import (
|
||||||
"github.com/serverwentdown/datetime.link/data"
|
"github.com/serverwentdown/datetime.link/data"
|
||||||
)
|
)
|
||||||
|
|
||||||
var regexName = regexp.MustCompile(`[^a-zA-Z1-9]+`)
|
var regexName = regexp.MustCompile(`[^a-zA-Z1-9']+`)
|
||||||
|
|
||||||
func extendRef(refs ...string) string {
|
func extendRef(refs ...string) string {
|
||||||
return strings.Join(refs, "-")
|
return strings.Join(refs, "-")
|
||||||
|
|
|
@ -0,0 +1,81 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var templateFuncs = map[string]interface{}{
|
||||||
|
"statusText": templateFuncStatusText,
|
||||||
|
"thisIsSafe": templateFuncThisIsSafe,
|
||||||
|
// Formatting
|
||||||
|
"formatOffset": templateFuncFormatOffset,
|
||||||
|
// Logic
|
||||||
|
"resolveZone": templateFuncResolveZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateFuncStatusText(s int) string {
|
||||||
|
return http.StatusText(s)
|
||||||
|
}
|
||||||
|
func templateFuncThisIsSafe(s string) template.HTML {
|
||||||
|
return template.HTML(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateFuncFormatOffset(offset int) string {
|
||||||
|
return FormatZoneOffset(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvedZone holds a resolved zone or an error
|
||||||
|
type ResolvedZone struct {
|
||||||
|
Zone
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateFuncResolveZone(app Datetime, zone string) ResolvedZone {
|
||||||
|
z, err := ResolveZone(app.cities, zone)
|
||||||
|
if err != nil {
|
||||||
|
l.Debug("unable to resolve zone", zap.Reflect("zone", zone), zap.Error(err))
|
||||||
|
}
|
||||||
|
return ResolvedZone{z, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTemplate returns a matching template for the request. It also causes an
|
||||||
|
// error if the template is not found or the Accept parameters are incorrect.
|
||||||
|
func (app Datetime) loadTemplate(name string, w http.ResponseWriter, req *http.Request) *template.Template {
|
||||||
|
accept := req.Header.Get("Accept")
|
||||||
|
tmpl, contentType, acceptable := app.chooseTemplate(accept, name)
|
||||||
|
if !acceptable {
|
||||||
|
app.simpleError(HTTPError{http.StatusNotAcceptable, nil}, w, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if tmpl == nil {
|
||||||
|
l.Error("unable to find template", zap.String("name", name), zap.String("accept", accept))
|
||||||
|
app.simpleError(HTTPError{http.StatusInternalServerError, ErrNoTemplate}, w, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
return tmpl
|
||||||
|
}
|
||||||
|
|
||||||
|
// chooseTemplate returns a template based on the accepted mime types from the
|
||||||
|
// client, and if a template cannot be found it returns a nil template.
|
||||||
|
func (app Datetime) chooseTemplate(accept string, name string) (t *template.Template, contentType string, acceptable bool) {
|
||||||
|
acceptable = true
|
||||||
|
switch chooseResponseType(accept) {
|
||||||
|
case responsePlain:
|
||||||
|
t = app.tmpl.Lookup(name + ".txt")
|
||||||
|
contentType = "text/plain"
|
||||||
|
case responseHTML:
|
||||||
|
t = app.tmpl.Lookup(name + ".html")
|
||||||
|
contentType = "text/html"
|
||||||
|
case responseAny:
|
||||||
|
t = app.tmpl.Lookup(name + ".txt")
|
||||||
|
contentType = "text/plain"
|
||||||
|
case responseUnknown:
|
||||||
|
acceptable = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTemplateFuncFormatOffset(t *testing.T) {
|
||||||
|
want, got := "+06:06", templateFuncFormatOffset(6*60*60+6*60)
|
||||||
|
if want != got {
|
||||||
|
t.Fatalf("got offset %v, want offset %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
want, got = "-12:15", templateFuncFormatOffset(-(12*60*60 + 15*60))
|
||||||
|
if want != got {
|
||||||
|
t.Fatalf("got offset %v, want offset %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
want, got = "\u00B100:00", templateFuncFormatOffset(-(0*60*60 + 0*60))
|
||||||
|
if want != got {
|
||||||
|
t.Fatalf("got offset %v, want offset %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
want, got = "+00:01", templateFuncFormatOffset(0*60*60+1*60)
|
||||||
|
if want != got {
|
||||||
|
t.Fatalf("got offset %v, want offset %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{.Status | statusText}} - datetime.link</title>
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
|
||||||
|
{{template "resources.html"}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "resources-body.html"}}
|
||||||
|
|
||||||
|
<main id="app">
|
||||||
|
<h1 title="Status Code: {{.Status}}">{{.Status | statusText}}</h1>
|
||||||
|
{{if eq .Status 400}}
|
||||||
|
<p title="Error: {{.Error.Error}}">The URL path is not valid</p>
|
||||||
|
{{else}}
|
||||||
|
<p>{{.Error.Error}}</p>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{template "footer.html"}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
{{.Status}} {{.Status | statusText | thisIsSafe}}
|
||||||
|
{{.Error | thisIsSafe}}
|
|
@ -0,0 +1,11 @@
|
||||||
|
<footer>
|
||||||
|
<ul class="list-inline">
|
||||||
|
<li><a href="https://github.com/serverwentdown/datetime.link" target="_blank">About datetime.link</a></li><!--
|
||||||
|
--><li><a href="https://github.com/serverwentdown/datetime.link/issues/new" target="_blank">Found a bug?</a></li><!--
|
||||||
|
--><li onclick="toggleTheme()"><!--
|
||||||
|
--><span class="icon theme-toggle-system">{{template "icon_solid_adjust.svg"}}</span><span class="theme-toggle-name theme-toggle-system"> System theme</span><!--
|
||||||
|
--><span class="icon theme-toggle-dark">{{template "icon_solid_moon.svg"}}</span><span class="theme-toggle-name theme-toggle-dark"> Dark theme</span><!--
|
||||||
|
--><span class="icon theme-toggle-light">{{template "icon_solid_sun.svg"}}</span><span class="theme-toggle-name theme-toggle-light"> Light theme</span><!--
|
||||||
|
--></li>
|
||||||
|
</ul>
|
||||||
|
</footer>
|
|
@ -4,13 +4,38 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>datetime.link</title>
|
<title>datetime.link</title>
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
{{template "resources.html"}}
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="/js/theme.js"></script>
|
{{template "resources-body.html"}}
|
||||||
|
|
||||||
<main id="app">
|
<main id="app">
|
||||||
|
{{$app := .App}}
|
||||||
|
{{$t := .Req.Time}}
|
||||||
|
{{range .Req.Zones}}
|
||||||
|
<d-zone zone="{{.}}">
|
||||||
|
{{with resolveZone $app .}}
|
||||||
|
{{if .Error}}
|
||||||
|
<d-zoneerror>
|
||||||
|
Unable to load zone. The URL path might not be valid
|
||||||
|
</d-zoneerror>
|
||||||
|
{{else}}
|
||||||
|
{{$zt := $t.In .Location}}
|
||||||
|
<d-zoneinfo>
|
||||||
|
<d-zonename>{{.Name}}</d-zonename>
|
||||||
|
{{if not .IsOffset}}
|
||||||
|
<d-zoneoffset>{{.TimeOffset $t | formatOffset}}</d-zoneoffset>
|
||||||
|
{{end}}
|
||||||
|
<d-date date="{{$zt.Format "2006-01-02"}}">{{$zt.Format "2006-01-02"}}</d-date>
|
||||||
|
</d-zoneinfo>
|
||||||
|
<d-zonefigure>
|
||||||
|
<d-time time="{{$zt.Format "15:04"}}">{{$zt.Format "15:04"}}</d-time>
|
||||||
|
</d-zonefigure>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</d-zone>
|
||||||
|
{{end}}
|
||||||
<!--
|
<!--
|
||||||
<d-zone>
|
<d-zone>
|
||||||
<d-zoneinfo>
|
<d-zoneinfo>
|
||||||
|
@ -45,17 +70,7 @@
|
||||||
-->
|
-->
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
{{template "footer.html"}}
|
||||||
<ul class="list-inline">
|
|
||||||
<li><a href="https://github.com/serverwentdown/datetime.link" target="_blank">About datetime.link</a></li><!--
|
|
||||||
--><li><a href="https://github.com/serverwentdown/datetime.link/issues/new" target="_blank">Found a bug?</a></li><!--
|
|
||||||
--><li onclick="toggleTheme()"><!--
|
|
||||||
--><span class="icon theme-toggle-system">{{template "icon_solid_adjust.svg"}}</span><span class="theme-toggle-name theme-toggle-system"> System theme</span><!--
|
|
||||||
--><span class="icon theme-toggle-dark">{{template "icon_solid_moon.svg"}}</span><span class="theme-toggle-name theme-toggle-dark"> Dark theme</span><!--
|
|
||||||
--><span class="icon theme-toggle-light">{{template "icon_solid_sun.svg"}}</span><span class="theme-toggle-name theme-toggle-light"> Light theme</span><!--
|
|
||||||
--></li>
|
|
||||||
</ul>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- Work in progress...
|
<!-- Work in progress...
|
||||||
<script src="/js/third-party/luxon.min.js"></script>
|
<script src="/js/third-party/luxon.min.js"></script>
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<script src="/js/theme.js"></script>
|
|
@ -0,0 +1,2 @@
|
||||||
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||||
|
<link rel="stylesheet" href="/css/styles.css">
|
10
url.go
10
url.go
|
@ -2,16 +2,20 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrMissingComponent is thrown when the URL has empty or missing components
|
// ErrMissingComponent is thrown when the URL has empty or missing components
|
||||||
var ErrMissingComponent = errors.New("missing URL component")
|
var ErrMissingComponent = errors.New("missing path component")
|
||||||
|
|
||||||
// ErrTooManyComponent is thrown when there are more than 2 components
|
// ErrTooManyComponent is thrown when there are more than 2 components
|
||||||
var ErrTooManyComponent = errors.New("too many components")
|
var ErrTooManyComponent = errors.New("too many path components")
|
||||||
|
|
||||||
|
// ErrInvalidTime is thrown in a time.ParseError
|
||||||
|
var ErrInvalidTime = errors.New("invalid ISO 8601 time")
|
||||||
|
|
||||||
var timeRFC3339NoSec = "2006-01-02T15:04Z07:00"
|
var timeRFC3339NoSec = "2006-01-02T15:04Z07:00"
|
||||||
var timeFormats = []string{time.RFC3339, timeRFC3339NoSec}
|
var timeFormats = []string{time.RFC3339, timeRFC3339NoSec}
|
||||||
|
@ -47,7 +51,7 @@ func ParseRequest(u *url.URL) (Request, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Request{}, err
|
return Request{}, fmt.Errorf("%w: %v", ErrInvalidTime, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split zones
|
// Split zones
|
||||||
|
|
14
url_test.go
14
url_test.go
|
@ -25,7 +25,7 @@ func TestURLParse(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
want := Request{
|
want := Request{
|
||||||
time.Date(2020, 6, 2, 14, 0, 0, 0, time.FixedZone("UTC +8", 8*60*60)),
|
time.Date(2020, time.June, 2, 14, 0, 0, 0, time.FixedZone("UTC +8", 8*60*60)),
|
||||||
[]string{"Singapore", "Malaysia"},
|
[]string{"Singapore", "Malaysia"},
|
||||||
}
|
}
|
||||||
if !cmp.Equal(got, want) {
|
if !cmp.Equal(got, want) {
|
||||||
|
@ -39,7 +39,7 @@ func TestURLParse(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
want = Request{
|
want = Request{
|
||||||
time.Date(2019, 4, 30, 18, 0, 0, 0, time.FixedZone("UTC", 0)),
|
time.Date(2019, time.April, 30, 18, 0, 0, 0, time.FixedZone("UTC", 0)),
|
||||||
[]string{"Nowhere"},
|
[]string{"Nowhere"},
|
||||||
}
|
}
|
||||||
if !cmp.Equal(got, want) {
|
if !cmp.Equal(got, want) {
|
||||||
|
@ -74,15 +74,13 @@ func TestURLParseFail(t *testing.T) {
|
||||||
|
|
||||||
u = mustURLParse("http://test/2000-01-13T00:00Z08:00/hi")
|
u = mustURLParse("http://test/2000-01-13T00:00Z08:00/hi")
|
||||||
_, err = ParseRequest(u)
|
_, err = ParseRequest(u)
|
||||||
_, isParseError := err.(*time.ParseError)
|
if !errors.Is(err, ErrInvalidTime) {
|
||||||
if !isParseError {
|
t.Errorf("got error %v, want error %v", err, ErrInvalidTime)
|
||||||
t.Errorf("got error %v, want time.ParseError", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
u = mustURLParse("http://test/2000-01-13 00:00+08:00/hi")
|
u = mustURLParse("http://test/2000-01-13 00:00+08:00/hi")
|
||||||
_, err = ParseRequest(u)
|
_, err = ParseRequest(u)
|
||||||
_, isParseError = err.(*time.ParseError)
|
if !errors.Is(err, ErrInvalidTime) {
|
||||||
if !isParseError {
|
t.Errorf("got error %v, want error %v", err, ErrInvalidTime)
|
||||||
t.Errorf("got error %v, want time.ParseError", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
73
zone.go
73
zone.go
|
@ -2,11 +2,13 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/serverwentdown/datetime.link/data"
|
"github.com/serverwentdown/datetime.link/data"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrZoneNotFound is thrown when a zone string has no match
|
// ErrZoneNotFound is thrown when a zone string has no match
|
||||||
|
@ -15,6 +17,9 @@ var ErrZoneNotFound = errors.New("zone not found")
|
||||||
// ErrZoneOffsetInvalid is thrown when a zone string is an invalid zone offset
|
// ErrZoneOffsetInvalid is thrown when a zone string is an invalid zone offset
|
||||||
var ErrZoneOffsetInvalid = errors.New("offset zone invalid")
|
var ErrZoneOffsetInvalid = errors.New("offset zone invalid")
|
||||||
|
|
||||||
|
const zoneHour = 60 * 60
|
||||||
|
const zoneMinute = 60
|
||||||
|
|
||||||
var zoneOffsetRegexp = regexp.MustCompile(`^[+-][0-9]{2}:[0-9]{2}$`)
|
var zoneOffsetRegexp = regexp.MustCompile(`^[+-][0-9]{2}:[0-9]{2}$`)
|
||||||
|
|
||||||
// SearchCities looks up a city by it's reference
|
// SearchCities looks up a city by it's reference
|
||||||
|
@ -27,9 +32,9 @@ func SearchCities(cities map[string]*data.City, city string) (*data.City, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseZoneOffset parses a zone string into a time.Location
|
// ParseZoneOffset parses a zone string into a time.Location
|
||||||
func ParseZoneOffset(zone string) (*time.Location, error) {
|
func ParseZoneOffset(zone string) (int, error) {
|
||||||
if !zoneOffsetRegexp.MatchString(zone) {
|
if !zoneOffsetRegexp.MatchString(zone) {
|
||||||
return nil, ErrZoneOffsetInvalid
|
return 0, ErrZoneOffsetInvalid
|
||||||
}
|
}
|
||||||
// Assume that if it satisfies the regex, it satisfies the length and won't
|
// Assume that if it satisfies the regex, it satisfies the length and won't
|
||||||
// fail to parse
|
// fail to parse
|
||||||
|
@ -44,9 +49,26 @@ func ParseZoneOffset(zone string) (*time.Location, error) {
|
||||||
// Allow hour offsets greater that 24
|
// Allow hour offsets greater that 24
|
||||||
m, _ := strconv.ParseUint(zone[1+3:1+3+2], 10, 64)
|
m, _ := strconv.ParseUint(zone[1+3:1+3+2], 10, 64)
|
||||||
if m >= 60 {
|
if m >= 60 {
|
||||||
return nil, ErrZoneOffsetInvalid
|
return 0, ErrZoneOffsetInvalid
|
||||||
}
|
}
|
||||||
return time.FixedZone("UTC"+zone, d*(int(h)*60*60+int(m)*60)), nil
|
offset := d * (int(h)*zoneHour + int(m)*zoneMinute)
|
||||||
|
return offset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatZoneOffset formats an offset into a string
|
||||||
|
func FormatZoneOffset(offset int) string {
|
||||||
|
neg := offset < 0
|
||||||
|
s := '+'
|
||||||
|
if neg {
|
||||||
|
s = '-'
|
||||||
|
offset = -offset
|
||||||
|
}
|
||||||
|
if offset == 0 {
|
||||||
|
return "\u00B100:00"
|
||||||
|
}
|
||||||
|
h := offset / zoneHour
|
||||||
|
m := (offset % zoneHour) / zoneMinute
|
||||||
|
return fmt.Sprintf("%c%02d:%02d", s, h, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zone represents any form of zone offset: this could be a city or a fixed
|
// Zone represents any form of zone offset: this could be a city or a fixed
|
||||||
|
@ -58,13 +80,54 @@ type Zone struct {
|
||||||
Offset *time.Location
|
Offset *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsOffset is true when the Zone is an offset instead of a city
|
||||||
|
func (z Zone) IsOffset() bool {
|
||||||
|
return z.Offset != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the name of the zone
|
||||||
|
func (z Zone) Name() string {
|
||||||
|
if z.IsOffset() {
|
||||||
|
return z.Offset.String()
|
||||||
|
} else if z.City != nil {
|
||||||
|
return z.City.FullName()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location returns the time.Location of the zone. Useful for other functions
|
||||||
|
func (z Zone) Location() *time.Location {
|
||||||
|
if z.IsOffset() {
|
||||||
|
return z.Offset
|
||||||
|
} else if z.City != nil {
|
||||||
|
loc, err := time.LoadLocation(z.City.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
// This is a really bad situation
|
||||||
|
// TODO: validate this at boot time
|
||||||
|
l.Error("unable to find timezone", zap.String("timezone", z.City.Timezone))
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeOffset returns the timezone offset at a specific time
|
||||||
|
func (z Zone) TimeOffset(t time.Time) int {
|
||||||
|
if l := z.Location(); l != nil {
|
||||||
|
_, offset := t.In(l).Zone()
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
return -1 // TODO: better invalid handling
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveZone resolves a zone string into a Zone
|
// ResolveZone resolves a zone string into a Zone
|
||||||
func ResolveZone(cities map[string]*data.City, zone string) (Zone, error) {
|
func ResolveZone(cities map[string]*data.City, zone string) (Zone, error) {
|
||||||
// Try parsing as a zone offset
|
// Try parsing as a zone offset
|
||||||
offset, err := ParseZoneOffset(zone)
|
offset, err := ParseZoneOffset(zone)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
offsetZone := time.FixedZone("UTC "+FormatZoneOffset(offset), offset)
|
||||||
return Zone{
|
return Zone{
|
||||||
Offset: offset,
|
Offset: offsetZone,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
// Parse as a city
|
// Parse as a city
|
||||||
|
|
73
zone_test.go
73
zone_test.go
|
@ -8,10 +8,51 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseZoneOffset(t *testing.T) {
|
func TestParseZoneOffset(t *testing.T) {
|
||||||
loc, err := ParseZoneOffset("+08:00")
|
offset, err := ParseZoneOffset("+08:00")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("want error %v, got error %v", nil, err)
|
t.Errorf("want error %v, got error %v", nil, err)
|
||||||
} else {
|
} else {
|
||||||
|
want := 8*60*60 + 0*60
|
||||||
|
if offset != want {
|
||||||
|
t.Errorf("got %d, want %d", offset, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset, err = ParseZoneOffset("-01:30")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("want error %v, got error %v", nil, err)
|
||||||
|
} else {
|
||||||
|
want := -(1*60*60 + 30*60)
|
||||||
|
if offset != want {
|
||||||
|
t.Errorf("got %d, want %d", offset, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ParseZoneOffset("-0030")
|
||||||
|
if err != ErrZoneOffsetInvalid {
|
||||||
|
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ParseZoneOffset("00:30")
|
||||||
|
if err != ErrZoneOffsetInvalid {
|
||||||
|
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ParseZoneOffset("+08:60")
|
||||||
|
if err != ErrZoneOffsetInvalid {
|
||||||
|
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ParseZoneOffset("+08:-6")
|
||||||
|
if err != ErrZoneOffsetInvalid {
|
||||||
|
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset, err = ParseZoneOffset("+08:00")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("want error %v, got error %v", nil, err)
|
||||||
|
} else {
|
||||||
|
loc := time.FixedZone("UTC "+FormatZoneOffset(offset), offset)
|
||||||
time := time.Date(2020, time.November, 8, 23, 9, 0, 0, loc).Unix()
|
time := time.Date(2020, time.November, 8, 23, 9, 0, 0, loc).Unix()
|
||||||
want := int64(1604848140)
|
want := int64(1604848140)
|
||||||
if time != want {
|
if time != want {
|
||||||
|
@ -19,36 +60,17 @@ func TestParseZoneOffset(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loc, err = ParseZoneOffset("-00:30")
|
offset, err = ParseZoneOffset("-00:30")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("want error %v, got error %v", nil, err)
|
t.Errorf("want error %v, got error %v", nil, err)
|
||||||
} else {
|
} else {
|
||||||
|
loc := time.FixedZone("UTC "+FormatZoneOffset(offset), offset)
|
||||||
time := time.Date(2020, time.November, 8, 14, 39, 0, 0, loc).Unix()
|
time := time.Date(2020, time.November, 8, 14, 39, 0, 0, loc).Unix()
|
||||||
want := int64(1604848140)
|
want := int64(1604848140)
|
||||||
if time != want {
|
if time != want {
|
||||||
t.Errorf("got %d, want %d", time, want)
|
t.Errorf("got %d, want %d", time, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loc, err = ParseZoneOffset("-0030")
|
|
||||||
if err != ErrZoneOffsetInvalid {
|
|
||||||
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loc, err = ParseZoneOffset("00:30")
|
|
||||||
if err != ErrZoneOffsetInvalid {
|
|
||||||
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loc, err = ParseZoneOffset("+08:60")
|
|
||||||
if err != ErrZoneOffsetInvalid {
|
|
||||||
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loc, err = ParseZoneOffset("+08:-6")
|
|
||||||
if err != ErrZoneOffsetInvalid {
|
|
||||||
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchCities(t *testing.T) {
|
func TestSearchCities(t *testing.T) {
|
||||||
|
@ -109,9 +131,10 @@ func TestResolveZone(t *testing.T) {
|
||||||
if zone.City != nil {
|
if zone.City != nil {
|
||||||
t.Errorf("want City %v, got City %v", nil, zone.City)
|
t.Errorf("want City %v, got City %v", nil, zone.City)
|
||||||
}
|
}
|
||||||
wantOffset, _ := ParseZoneOffset("+04:00")
|
gotTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, zone.Offset)
|
||||||
if zone.Offset.String() != wantOffset.String() {
|
wantTime := time.Date(2019, time.December, 31, 20, 0, 0, 0, time.UTC)
|
||||||
t.Errorf("want Offset %v, got Offset %v", wantOffset, zone.Offset)
|
if !gotTime.Equal(wantTime) {
|
||||||
|
t.Errorf("want time %v, got time %v", wantTime, gotTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
zone, err = ResolveZone(cities, "+04:80")
|
zone, err = ResolveZone(cities, "+04:80")
|
||||||
|
|
Loading…
Reference in New Issue