mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/i18n"
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
)
|
||||
|
||||
// DefaultLocale returns the default user interface language locale name.
|
||||
@@ -23,12 +24,12 @@ func (c *Config) DefaultLocale() string {
|
||||
// DefaultTimezone returns the default time zone, e.g. for scheduling backups
|
||||
func (c *Config) DefaultTimezone() *time.Location {
|
||||
if c.options.DefaultTimezone == "" {
|
||||
return time.Local
|
||||
return tz.TimeLocal
|
||||
}
|
||||
|
||||
// Returns time zone if a valid identifier name was provided and UTC otherwise.
|
||||
if timeZone, err := time.LoadLocation(c.options.DefaultTimezone); err != nil || timeZone == nil {
|
||||
return time.Local
|
||||
return tz.TimeLocal
|
||||
} else {
|
||||
return timeZone
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package customize
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/i18n"
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -11,5 +10,5 @@ var (
|
||||
DefaultStartPage = "default"
|
||||
DefaultMapsStyle = "default"
|
||||
DefaultLanguage = i18n.Default.Locale()
|
||||
DefaultTimeZone = time.Local.String()
|
||||
DefaultTimeZone = tz.Local
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/cpuid/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -17,6 +16,7 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/http/header"
|
||||
"github.com/photoprism/photoprism/pkg/media/http/scheme"
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
@@ -502,7 +502,7 @@ var Flags = CliFlags{
|
||||
Name: "default-timezone",
|
||||
Aliases: []string{"tz"},
|
||||
Usage: "default time zone `NAME`, e.g. for scheduling backups",
|
||||
Value: time.Local.String(),
|
||||
Value: tz.Local,
|
||||
EnvVars: EnvVars("DEFAULT_TIMEZONE"),
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ func (m *Photo) TrustedTime() bool {
|
||||
return false
|
||||
} else if m.TakenAt.IsZero() || m.TakenAtLocal.IsZero() {
|
||||
return false
|
||||
} else if m.TimeZone == "" || m.TimeZone == time.Local.String() {
|
||||
} else if tz.Name(m.TimeZone) == tz.Local {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -31,30 +31,33 @@ func (m *Photo) SetTakenAt(utc, local time.Time, zone, source string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Round times to avoid jitter.
|
||||
utc = utc.UTC().Truncate(time.Second)
|
||||
// Normalize time zone string.
|
||||
zone = tz.Name(zone)
|
||||
|
||||
// Ignore sub-seconds to avoid jitter.
|
||||
utc = tz.TruncateUTC(utc)
|
||||
|
||||
// Default local time to taken if zero or invalid.
|
||||
if local.IsZero() || local.Year() < 1000 {
|
||||
local = utc
|
||||
} else {
|
||||
local = local.Truncate(time.Second)
|
||||
local = tz.TruncateLocal(local)
|
||||
}
|
||||
|
||||
// If no zone is specified, assume the current zone or try to determine
|
||||
// the time zone based on the time offset. Otherwise, default to Local.
|
||||
if source == SrcName && m.TimeZone == "" {
|
||||
if source == SrcName && tz.Name(zone) == tz.Local && tz.Name(m.TimeZone) == tz.Local {
|
||||
// Assume Local timezone if the time was extracted from a filename.
|
||||
zone = time.Local.String()
|
||||
} else if zone == "" {
|
||||
if m.TimeZone != "" {
|
||||
zone = tz.Local
|
||||
} else if zone == tz.Unknown {
|
||||
if m.TimeZone != tz.Unknown {
|
||||
zone = m.TimeZone
|
||||
} else if !utc.Equal(local) {
|
||||
zone = txt.UtcOffset(utc, local, "")
|
||||
zone = tz.UtcOffset(utc, local, "")
|
||||
}
|
||||
|
||||
if zone == "" {
|
||||
zone = time.Local.String()
|
||||
if zone == tz.Unknown {
|
||||
zone = tz.Local
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,15 +66,22 @@ func (m *Photo) SetTakenAt(utc, local time.Time, zone, source string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use location time zone if it has a higher priority.
|
||||
if SrcPriority[source] < SrcPriority[m.PlaceSrc] && m.HasLatLng() {
|
||||
if locZone := m.LocationTimeZone(); locZone != "" {
|
||||
zone = locZone
|
||||
}
|
||||
}
|
||||
|
||||
// Set UTC time and date source.
|
||||
m.TakenAt = utc
|
||||
m.TakenAtLocal = local
|
||||
m.TakenSrc = source
|
||||
|
||||
if zone == time.UTC.String() && m.TimeZone != "" {
|
||||
if zone == time.UTC.String() && m.TimeZone != tz.Local {
|
||||
// Location exists, set local time from UTC.
|
||||
m.TakenAtLocal = m.GetTakenAtLocal()
|
||||
} else if zone != "" {
|
||||
} else if zone != tz.Local {
|
||||
// Apply new time zone.
|
||||
m.TimeZone = zone
|
||||
m.TakenAt = m.GetTakenAt()
|
||||
@@ -81,7 +91,7 @@ func (m *Photo) SetTakenAt(utc, local time.Time, zone, source string) {
|
||||
if m.TimeZoneUTC() {
|
||||
m.TakenAtLocal = utc
|
||||
}
|
||||
} else if m.TimeZone != "" {
|
||||
} else if m.TimeZone != tz.Local {
|
||||
// Apply existing time zone.
|
||||
m.TakenAt = m.GetTakenAt()
|
||||
}
|
||||
@@ -91,16 +101,18 @@ func (m *Photo) SetTakenAt(utc, local time.Time, zone, source string) {
|
||||
|
||||
// TimeZoneUTC tests if the current time zone is UTC.
|
||||
func (m *Photo) TimeZoneUTC() bool {
|
||||
return strings.EqualFold(m.TimeZone, time.UTC.String())
|
||||
return tz.IsUTC(m.TimeZone)
|
||||
}
|
||||
|
||||
// UpdateTimeZone updates the time zone.
|
||||
func (m *Photo) UpdateTimeZone(zone string) {
|
||||
if zone == "" || zone == time.UTC.String() || zone == m.TimeZone {
|
||||
if zone == "" {
|
||||
return
|
||||
} else if zone = tz.Name(zone); zone == tz.Local || zone == tz.UTC || zone == tz.Name(m.TimeZone) {
|
||||
return
|
||||
}
|
||||
|
||||
if SrcPriority[m.TakenSrc] >= SrcPriority[SrcManual] && m.TimeZone != "" && m.TimeZone != time.Local.String() {
|
||||
if SrcPriority[m.TakenSrc] >= SrcPriority[SrcManual] && !tz.IsLocal(m.TimeZone) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
)
|
||||
|
||||
func TestPhoto_TrustedTime(t *testing.T) {
|
||||
@@ -20,7 +20,7 @@ func TestPhoto_TrustedTime(t *testing.T) {
|
||||
})
|
||||
t.Run("MissingTimeZone", func(t *testing.T) {
|
||||
n := Now()
|
||||
m := Photo{ID: 1, TakenAt: n, TakenAtLocal: n, TakenSrc: SrcMeta, TimeZone: time.Local.String()}
|
||||
m := Photo{ID: 1, TakenAt: n, TakenAtLocal: n, TakenSrc: SrcMeta, TimeZone: tz.Local}
|
||||
assert.False(t, m.TrustedTime())
|
||||
})
|
||||
t.Run("SrcAuto", func(t *testing.T) {
|
||||
@@ -56,8 +56,9 @@ func TestPhoto_SetTakenAt(t *testing.T) {
|
||||
})
|
||||
t.Run("FromName", func(t *testing.T) {
|
||||
m := PhotoFixtures.Get("Photo15")
|
||||
m.TimeZone = time.Local.String()
|
||||
m.TimeZone = tz.Local
|
||||
m.TakenSrc = SrcAuto
|
||||
m.PlaceSrc = SrcAuto
|
||||
|
||||
assert.Equal(t, time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC), m.TakenAt)
|
||||
assert.Equal(t, time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC), m.TakenAtLocal)
|
||||
@@ -72,6 +73,14 @@ func TestPhoto_SetTakenAt(t *testing.T) {
|
||||
|
||||
assert.Equal(t, time.Date(2019, 11, 11, 15, 7, 18, 0, time.UTC), m.TakenAt)
|
||||
assert.Equal(t, time.Date(2019, 11, 11, 10, 7, 18, 0, time.UTC), m.TakenAtLocal)
|
||||
|
||||
m.PlaceSrc = SrcMeta
|
||||
|
||||
m.SetTakenAt(time.Date(2011, 12, 11, 9, 7, 18, 0, time.UTC),
|
||||
time.Date(2019, 11, 11, 10, 7, 18, 0, time.UTC), "America/New_York", SrcName)
|
||||
|
||||
assert.Equal(t, "Europe/Berlin", m.TimeZone)
|
||||
assert.Equal(t, SrcName, m.TakenSrc)
|
||||
})
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
m := PhotoFixtures.Get("Photo15")
|
||||
@@ -117,7 +126,7 @@ func TestPhoto_SetTakenAt(t *testing.T) {
|
||||
m := PhotoFixtures.Get("Photo15")
|
||||
|
||||
timeZone := "Europe/Berlin"
|
||||
loc := txt.TimeZone(timeZone)
|
||||
loc := tz.Find(timeZone)
|
||||
utcTime := time.Date(2013, 11, 11, 9, 7, 18, 0, loc)
|
||||
localTime := utcTime
|
||||
|
||||
@@ -170,7 +179,7 @@ func TestPhoto_SetTakenAt(t *testing.T) {
|
||||
photo.SetTakenAt(time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC),
|
||||
time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), "", SrcManual)
|
||||
assert.Equal(t, time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC), photo.TakenAt)
|
||||
assert.Equal(t, time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC), photo.TakenAtLocal)
|
||||
assert.Equal(t, time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), photo.TakenAtLocal)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -183,7 +192,7 @@ func TestPhoto_UpdateTimeZone(t *testing.T) {
|
||||
takenShanghaiUtc := time.Date(2015, time.May, 17, 15, 2, 46, 0, time.UTC)
|
||||
|
||||
assert.Equal(t, "Local", m.TimeZone)
|
||||
assert.Equal(t, time.Local.String(), m.TimeZone)
|
||||
assert.Equal(t, tz.Local, m.TimeZone)
|
||||
assert.Equal(t, takenLocal, m.TakenAt)
|
||||
assert.Equal(t, takenLocal, m.TakenAtLocal)
|
||||
|
||||
|
||||
@@ -961,8 +961,8 @@ var PhotoFixtures = PhotoMap{
|
||||
CellID: CellFixtures.Pointer("Neckarbrücke").ID,
|
||||
CellAccuracy: 0,
|
||||
PhotoAltitude: 3,
|
||||
PhotoLat: 1.234,
|
||||
PhotoLng: 4.321,
|
||||
PhotoLat: 48.5188477,
|
||||
PhotoLng: 9.0531866,
|
||||
PhotoCountry: "de",
|
||||
PhotoYear: 2013,
|
||||
PhotoMonth: 11,
|
||||
|
||||
@@ -7,12 +7,11 @@ import (
|
||||
|
||||
"github.com/dustin/go-humanize/english"
|
||||
|
||||
"github.com/ugjka/go-tz/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ai/classify"
|
||||
"github.com/photoprism/photoprism/internal/service/maps"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/geo"
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
@@ -298,20 +297,7 @@ func (m *Photo) UnknownCountry() bool {
|
||||
// LocationTimeZone uses the GPS location to determine the time zone of the photo,
|
||||
// and returns "Local" if no location is set or no time zone is found.
|
||||
func (m *Photo) LocationTimeZone() string {
|
||||
result := time.Local.String()
|
||||
|
||||
if m.HasLatLng() {
|
||||
zones, err := tz.GetZone(tz.Point{
|
||||
Lat: m.PhotoLat,
|
||||
Lon: m.PhotoLng,
|
||||
})
|
||||
|
||||
if err == nil && len(zones) > 0 {
|
||||
result = zones[0]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return tz.Position(m.PhotoLat, m.PhotoLng)
|
||||
}
|
||||
|
||||
// CountryName returns the photo country name.
|
||||
@@ -334,7 +320,7 @@ func (m *Photo) CountryCode() string {
|
||||
|
||||
// GetTakenAt returns UTC time for TakenAtLocal.
|
||||
func (m *Photo) GetTakenAt() time.Time {
|
||||
location := txt.TimeZone(m.TimeZone)
|
||||
location := tz.Find(m.TimeZone)
|
||||
|
||||
if location == nil {
|
||||
return m.TakenAt
|
||||
@@ -349,9 +335,9 @@ func (m *Photo) GetTakenAt() time.Time {
|
||||
|
||||
// GetTakenAtLocal returns local time for TakenAt.
|
||||
func (m *Photo) GetTakenAtLocal() time.Time {
|
||||
location := txt.TimeZone(m.TimeZone)
|
||||
location := tz.Find(m.TimeZone)
|
||||
|
||||
if location == nil {
|
||||
if location == tz.TimeLocal {
|
||||
return m.TakenAtLocal
|
||||
}
|
||||
|
||||
|
||||
@@ -104,44 +104,44 @@ func TestPhoto_SetAltitude(t *testing.T) {
|
||||
t.Run("ViaSetCoordinates", func(t *testing.T) {
|
||||
m := PhotoFixtures.Get("Photo15")
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 3, m.PhotoAltitude)
|
||||
|
||||
m.SetCoordinates(0, 0, 5, SrcManual)
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 5, m.PhotoAltitude)
|
||||
})
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
m := PhotoFixtures.Get("Photo15")
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 3, m.PhotoAltitude)
|
||||
|
||||
m.SetAltitude(5, SrcManual)
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 5, m.PhotoAltitude)
|
||||
})
|
||||
t.Run("SkipUpdate", func(t *testing.T) {
|
||||
m := PhotoFixtures.Get("Photo15")
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 3, m.PhotoAltitude)
|
||||
|
||||
m.SetAltitude(5, SrcEstimate)
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 3, m.PhotoAltitude)
|
||||
})
|
||||
t.Run("UpdateEmptyAltitude", func(t *testing.T) {
|
||||
m := Photo{ID: 1, PlaceSrc: SrcMeta, PhotoLat: float64(1.234), PhotoLng: float64(4.321), PhotoAltitude: 0}
|
||||
m := Photo{ID: 1, PlaceSrc: SrcMeta, PhotoLat: float64(48.51885), PhotoLng: float64(9.0531866), PhotoAltitude: 0}
|
||||
|
||||
m.SetAltitude(-5, SrcAuto)
|
||||
assert.Equal(t, 0, m.PhotoAltitude)
|
||||
@@ -153,7 +153,7 @@ func TestPhoto_SetAltitude(t *testing.T) {
|
||||
assert.Equal(t, -5, m.PhotoAltitude)
|
||||
})
|
||||
t.Run("ZeroAltitudeManual", func(t *testing.T) {
|
||||
m := Photo{ID: 1, PlaceSrc: SrcManual, PhotoLat: 1.234, PhotoLng: 4.321, PhotoAltitude: 5}
|
||||
m := Photo{ID: 1, PlaceSrc: SrcManual, PhotoLat: 48.51885, PhotoLng: 9.0531866, PhotoAltitude: 5}
|
||||
|
||||
m.SetAltitude(0, SrcManual)
|
||||
assert.Equal(t, 0, m.PhotoAltitude)
|
||||
@@ -164,21 +164,21 @@ func TestPhoto_SetCoordinates(t *testing.T) {
|
||||
t.Run("empty coordinates", func(t *testing.T) {
|
||||
m := PhotoFixtures.Get("Photo15")
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 3, m.PhotoAltitude)
|
||||
|
||||
m.SetCoordinates(0, 0, 5, SrcManual)
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 5, m.PhotoAltitude)
|
||||
})
|
||||
t.Run("same source new values", func(t *testing.T) {
|
||||
m := PhotoFixtures.Get("Photo15")
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 3, m.PhotoAltitude)
|
||||
|
||||
m.SetCoordinates(5.555, 5.555, 5, SrcMeta)
|
||||
@@ -190,21 +190,21 @@ func TestPhoto_SetCoordinates(t *testing.T) {
|
||||
t.Run("different source lower priority", func(t *testing.T) {
|
||||
m := PhotoFixtures.Get("Photo15")
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 3, m.PhotoAltitude)
|
||||
|
||||
m.SetCoordinates(5.555, 5.555, 5, SrcName)
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 3, m.PhotoAltitude)
|
||||
})
|
||||
t.Run("different source equal priority", func(t *testing.T) {
|
||||
m := PhotoFixtures.Get("Photo15")
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 3, m.PhotoAltitude)
|
||||
|
||||
m.SetCoordinates(5.555, 5.555, 5, SrcKeyword)
|
||||
@@ -228,8 +228,8 @@ func TestPhoto_SetCoordinates(t *testing.T) {
|
||||
t.Run("different source highest priority (manual)", func(t *testing.T) {
|
||||
m := PhotoFixtures.Get("Photo15")
|
||||
assert.Equal(t, SrcMeta, m.PlaceSrc)
|
||||
assert.Equal(t, float32(1.234), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(4.321), float32(m.PhotoLng))
|
||||
assert.Equal(t, float32(48.51885), float32(m.PhotoLat))
|
||||
assert.Equal(t, float32(9.0531866), float32(m.PhotoLng))
|
||||
assert.Equal(t, 3, m.PhotoAltitude)
|
||||
|
||||
m.SetCoordinates(5.555, 5.555, 5, SrcManual)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/dsoprea/go-exif/v3"
|
||||
"github.com/ugjka/go-tz/v2"
|
||||
|
||||
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
||||
|
||||
@@ -19,6 +18,7 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
@@ -260,16 +260,14 @@ func (data *Data) Exif(fileName string, fileFormat fs.Type, bruteForce bool) (er
|
||||
}
|
||||
|
||||
if data.Lat != 0 && data.Lng != 0 {
|
||||
zones, zoneErr := tz.GetZone(tz.Point{
|
||||
Lat: data.Lat,
|
||||
Lon: data.Lng,
|
||||
})
|
||||
|
||||
if zoneErr == nil && len(zones) > 0 {
|
||||
data.TimeZone = zones[0]
|
||||
if zone := tz.Position(data.Lat, data.Lng); zone != "" {
|
||||
data.TimeZone = zone
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize time zone name.
|
||||
data.TimeZone = tz.Name(data.TimeZone)
|
||||
|
||||
takenAt := time.Time{}
|
||||
|
||||
for _, name := range exifDateTimeTags {
|
||||
|
||||
@@ -649,7 +649,7 @@ func TestExif(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "2021-10-29 13:42:00 +0000 UTC", data.TakenAtLocal.String())
|
||||
assert.Equal(t, "2021-10-29 13:42:00 +0000 UTC", data.TakenAt.String())
|
||||
assert.Equal(t, "", data.TimeZone) // Local Time
|
||||
assert.Equal(t, "Local", data.TimeZone) // Local Time
|
||||
assert.Equal(t, 1, data.Orientation)
|
||||
assert.Equal(t, 0.0, data.Lat)
|
||||
assert.Equal(t, 0.0, data.Lng)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/ugjka/go-tz/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
@@ -18,6 +17,7 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
"github.com/photoprism/photoprism/pkg/media/video"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
@@ -243,33 +243,23 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
|
||||
|
||||
// Set time zone and calculate UTC time.
|
||||
if data.Lat != 0 && data.Lng != 0 {
|
||||
zones, zoneErr := tz.GetZone(tz.Point{
|
||||
Lat: float64(data.Lat),
|
||||
Lon: float64(data.Lng),
|
||||
})
|
||||
|
||||
if zoneErr == nil && len(zones) > 0 {
|
||||
data.TimeZone = zones[0]
|
||||
if zone := tz.Position(data.Lat, data.Lng); zone != "" {
|
||||
data.TimeZone = zone
|
||||
}
|
||||
|
||||
if loc := txt.TimeZone(data.TimeZone); loc == nil {
|
||||
log.Warnf("metadata: %s has invalid time zone %s (exiftool)", logName)
|
||||
} else if !data.TakenAtLocal.IsZero() {
|
||||
if loc := tz.Find(data.TimeZone); !data.TakenAtLocal.IsZero() {
|
||||
if tl, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); parseErr == nil {
|
||||
if localUtc, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), time.UTC); err == nil {
|
||||
data.TakenAtLocal = localUtc
|
||||
}
|
||||
|
||||
data.TakenAtLocal = tz.Strip(data.TakenAtLocal)
|
||||
data.TakenAt = tl.Truncate(time.Second).UTC()
|
||||
} else {
|
||||
log.Errorf("metadata: %s (exiftool)", parseErr.Error()) // this should never happen
|
||||
log.Errorf("metadata: %s (exiftool)", clean.Error(parseErr)) // this should never happen
|
||||
}
|
||||
} else if !data.TakenAt.IsZero() {
|
||||
if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil {
|
||||
data.TakenAtLocal = localUtc
|
||||
data.TakenAt = data.TakenAt.UTC()
|
||||
} else {
|
||||
log.Errorf("metadata: %s (exiftool)", parseErr.Error()) // this should never happen
|
||||
log.Errorf("metadata: %s (exiftool)", clean.Error(parseErr)) // this should never happen
|
||||
}
|
||||
}
|
||||
} else if hasTimeOffset {
|
||||
@@ -281,24 +271,34 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
|
||||
}
|
||||
|
||||
// Set UTC offset as time zone?
|
||||
if data.TimeZone != "" && data.TimeZone != "Local" && data.TimeZone != "UTC" || data.TakenAt.IsZero() {
|
||||
if data.TimeZone != "" && data.TimeZone != tz.Local && data.TimeZone != tz.UTC || data.TakenAt.IsZero() {
|
||||
// Don't change existing time zone.
|
||||
} else if utcOffset := txt.UtcOffset(data.TakenAt, data.TakenAtLocal, data.TimeOffset); utcOffset != "" {
|
||||
} else if utcOffset := tz.UtcOffset(data.TakenAt, data.TakenAtLocal, data.TimeOffset); utcOffset != "" {
|
||||
data.TimeZone = utcOffset
|
||||
|
||||
if data.TakenAtLocal.IsZero() {
|
||||
data.TakenAtLocal = tz.Strip(data.TakenAt)
|
||||
}
|
||||
|
||||
data.TakenAt = data.TakenAt.UTC()
|
||||
log.Infof("metadata: %s has time offset %s (exiftool)", logName, clean.Log(utcOffset))
|
||||
} else if data.TimeOffset != "" {
|
||||
log.Infof("metadata: %s has invalid time offset %s (exiftool)", logName, clean.Log(data.TimeOffset))
|
||||
}
|
||||
|
||||
// Set local time if still empty.
|
||||
// Normalize time zone name.
|
||||
data.TimeZone = tz.Name(data.TimeZone)
|
||||
|
||||
// Set local time based on UTC time if empty.
|
||||
if data.TakenAtLocal.IsZero() && !data.TakenAt.IsZero() {
|
||||
if loc := txt.TimeZone(data.TimeZone); data.TimeZone == "" || data.TimeZone == "Local" || loc == nil {
|
||||
data.TakenAtLocal = data.TakenAt
|
||||
} else if localUtc, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); err == nil {
|
||||
if loc := tz.Find(data.TimeZone); loc.String() == tz.Local {
|
||||
data.TakenAtLocal = tz.Strip(data.TakenAt)
|
||||
data.TakenAt = data.TakenAt.UTC()
|
||||
} else if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil {
|
||||
data.TakenAtLocal = localUtc
|
||||
data.TakenAt = data.TakenAt.UTC()
|
||||
} else {
|
||||
log.Errorf("metadata: %s (exiftool)", err.Error()) // this should never happen
|
||||
log.Errorf("metadata: %s (exiftool)", clean.Error(parseErr)) // this should never happen
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/ugjka/go-tz/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
)
|
||||
|
||||
type GPhoto struct {
|
||||
@@ -139,19 +137,12 @@ func (data *Data) GPhoto(jsonData []byte) (err error) {
|
||||
|
||||
// Set time zone and calculate UTC time.
|
||||
if data.Lat != 0 && data.Lng != 0 {
|
||||
zones, zoneErr := tz.GetZone(tz.Point{
|
||||
Lat: data.Lat,
|
||||
Lon: data.Lng,
|
||||
})
|
||||
|
||||
if zoneErr == nil && len(zones) > 0 {
|
||||
data.TimeZone = zones[0]
|
||||
if zone := tz.Position(data.Lat, data.Lng); zone != "" {
|
||||
data.TimeZone = zone
|
||||
}
|
||||
|
||||
if !data.TakenAtLocal.IsZero() {
|
||||
if loc := txt.TimeZone(data.TimeZone); loc == nil {
|
||||
log.Warnf("metadata: invalid time zone %s (gphotos)", data.TimeZone)
|
||||
} else if tl, locErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); locErr == nil {
|
||||
if loc := tz.Find(data.TimeZone); !data.TakenAtLocal.IsZero() {
|
||||
if tl, locErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); locErr == nil {
|
||||
data.TakenAt = tl.UTC().Truncate(time.Second)
|
||||
} else {
|
||||
log.Errorf("metadata: %s (gphotos)", locErr.Error()) // this should never happen
|
||||
@@ -159,5 +150,8 @@ func (data *Data) GPhoto(jsonData []byte) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize time zone name.
|
||||
data.TimeZone = tz.Name(data.TimeZone)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -512,7 +512,7 @@ func TestJSON(t *testing.T) {
|
||||
assert.Equal(t, "In Hamburg", data.Caption)
|
||||
assert.Equal(t, "2011-11-07 21:34:34 +0000 UTC", data.TakenAtLocal.String())
|
||||
assert.Equal(t, "2011-11-07 21:34:34 +0000 UTC", data.TakenAt.String())
|
||||
assert.Equal(t, "", data.TimeZone)
|
||||
assert.Equal(t, "Local", data.TimeZone)
|
||||
assert.Equal(t, 0.0, data.Lat)
|
||||
assert.Equal(t, 0.0, data.Lng)
|
||||
assert.Equal(t, 0.0, data.Altitude)
|
||||
@@ -777,9 +777,9 @@ func TestJSON(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "jpeg", data.Codec)
|
||||
assert.Equal(t, "0s", data.Duration.String())
|
||||
assert.Equal(t, "2021-10-27 10:43:46 +0200 UTC+02:00", data.TakenAtLocal.String())
|
||||
assert.Equal(t, "2021-10-27 10:43:46 +0200 UTC+02:00", data.TakenAt.String())
|
||||
assert.Equal(t, "", data.TimeZone) // Local Time
|
||||
assert.Equal(t, "2021-10-27 10:43:46 +0000 UTC", data.TakenAtLocal.String())
|
||||
assert.Equal(t, "2021-10-27 08:43:46 +0000 UTC", data.TakenAt.String())
|
||||
assert.Equal(t, "UTC+2", data.TimeZone) // UTC Offset
|
||||
assert.Equal(t, 1, data.Orientation)
|
||||
assert.Equal(t, 0.0, data.Lat)
|
||||
assert.Equal(t, 0.0, data.Lng)
|
||||
@@ -817,7 +817,7 @@ func TestJSON(t *testing.T) {
|
||||
assert.Equal(t, "2015-03-20 12:07:53 +0000 UTC", data.TakenAtLocal.String())
|
||||
assert.Equal(t, "2015-03-20 12:07:53 +0000 UTC", data.TakenAt.String())
|
||||
assert.Equal(t, 0, data.TakenNs)
|
||||
assert.Equal(t, "", data.TimeZone)
|
||||
assert.Equal(t, "Local", data.TimeZone)
|
||||
assert.Equal(t, 4608, data.Width)
|
||||
assert.Equal(t, 3072, data.Height)
|
||||
assert.Equal(t, 4608, data.ActualWidth())
|
||||
@@ -843,7 +843,7 @@ func TestJSON(t *testing.T) {
|
||||
assert.Equal(t, "2015-03-20 12:07:53 +0000 UTC", data.TakenAtLocal.String())
|
||||
assert.Equal(t, "2015-03-20 12:07:53 +0000 UTC", data.TakenAt.String())
|
||||
assert.Equal(t, 0, data.TakenNs)
|
||||
assert.Equal(t, "", data.TimeZone)
|
||||
assert.Equal(t, "Local", data.TimeZone)
|
||||
assert.Equal(t, 4608, data.Width)
|
||||
assert.Equal(t, 3072, data.Height)
|
||||
assert.Equal(t, 4608, data.ActualWidth())
|
||||
@@ -868,7 +868,7 @@ func TestJSON(t *testing.T) {
|
||||
assert.Equal(t, "2016-09-07 12:49:23.373 +0000 UTC", data.TakenAtLocal.String())
|
||||
assert.Equal(t, "2016-09-07 12:49:23.373 +0000 UTC", data.TakenAt.String())
|
||||
assert.Equal(t, 373000000, data.TakenNs)
|
||||
assert.Equal(t, "", data.TimeZone)
|
||||
assert.Equal(t, "Local", data.TimeZone)
|
||||
assert.Equal(t, 4032, data.Width)
|
||||
assert.Equal(t, 3024, data.Height)
|
||||
assert.Equal(t, 4032, data.ActualWidth())
|
||||
@@ -894,7 +894,7 @@ func TestJSON(t *testing.T) {
|
||||
assert.Equal(t, "0s", data.Duration.String())
|
||||
assert.Equal(t, "2016-09-07 12:49:23.373 +0000 UTC", data.TakenAtLocal.String())
|
||||
assert.Equal(t, "2016-09-07 12:49:23.373 +0000 UTC", data.TakenAt.String())
|
||||
assert.Equal(t, "", data.TimeZone)
|
||||
assert.Equal(t, "Local", data.TimeZone)
|
||||
assert.Equal(t, 4032, data.Width)
|
||||
assert.Equal(t, 3024, data.Height)
|
||||
assert.Equal(t, 4032, data.ActualWidth())
|
||||
@@ -1368,7 +1368,7 @@ func TestJSON(t *testing.T) {
|
||||
assert.Equal(t, "0s", data.Duration.String())
|
||||
assert.Equal(t, "1990-08-01 12:11:50 +0000 UTC", data.TakenAtLocal.String())
|
||||
assert.Equal(t, "1990-08-01 12:11:50 +0000 UTC", data.TakenAt.String())
|
||||
assert.Equal(t, "", data.TimeZone)
|
||||
assert.Equal(t, "Local", data.TimeZone)
|
||||
assert.Equal(t, 5728, data.Width)
|
||||
assert.Equal(t, 3824, data.Height)
|
||||
assert.Equal(t, 1, data.Orientation)
|
||||
|
||||
@@ -3,12 +3,12 @@ package photoprism
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/meta"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media/video"
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
)
|
||||
|
||||
// HasSidecarJson returns true if this file has or is a json sidecar file.
|
||||
@@ -124,7 +124,7 @@ func (m *MediaFile) MetaData() (result meta.Data) {
|
||||
m.metaData.Error = err
|
||||
log.Debugf("%s in %s", err, clean.Log(m.BaseName()))
|
||||
} else if m.metaData.TimeZone == "" {
|
||||
m.metaData.TimeZone = time.Local.String()
|
||||
m.metaData.TimeZone = tz.Local
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
"github.com/photoprism/photoprism/pkg/media/video"
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
)
|
||||
|
||||
func TestMediaFile_HasSidecarJson(t *testing.T) {
|
||||
@@ -216,7 +217,7 @@ func TestMediaFile_Exif_Jpeg(t *testing.T) {
|
||||
assert.Equal(t, "EF100mm f/2.8L Macro IS USM", info.LensModel)
|
||||
assert.Equal(t, "", info.LensMake)
|
||||
assert.Equal(t, "Local", info.TimeZone)
|
||||
assert.Equal(t, time.Local.String(), info.TimeZone)
|
||||
assert.Equal(t, tz.Local, info.TimeZone)
|
||||
assert.Equal(t, "", info.Artist)
|
||||
assert.Equal(t, 100, info.FocalLength)
|
||||
assert.Equal(t, "1/250", info.Exposure)
|
||||
|
||||
11
pkg/time/tz/const.go
Normal file
11
pkg/time/tz/const.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package tz
|
||||
|
||||
// Time zones.
|
||||
const (
|
||||
Unknown = ""
|
||||
Local = "Local"
|
||||
UTC = "UTC"
|
||||
Zulu = "Z"
|
||||
GMT = "GMT"
|
||||
AsiaKathmandu = "Asia/Kathmandu"
|
||||
)
|
||||
33
pkg/time/tz/find.go
Normal file
33
pkg/time/tz/find.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package tz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Find returns the matching time zone location.
|
||||
func Find(name string) *time.Location {
|
||||
if IsUTC(name) {
|
||||
return time.UTC
|
||||
} else if IsLocal(name) {
|
||||
return TimeLocal
|
||||
}
|
||||
|
||||
// Normalize zone name.
|
||||
name = Name(name)
|
||||
|
||||
if offsetSec, offsetErr := Offset(name); offsetErr != nil {
|
||||
// Do nothing.
|
||||
} else if h := offsetSec / 3600; h > 0 || h < 0 {
|
||||
return time.FixedZone(fmt.Sprintf("UTC%+d", h), offsetSec)
|
||||
}
|
||||
|
||||
// Find location by name.
|
||||
if loc, err := time.LoadLocation(name); err != nil || loc == nil {
|
||||
// Return Local location if not found.
|
||||
return TimeLocal
|
||||
} else {
|
||||
// Return location.
|
||||
return loc
|
||||
}
|
||||
}
|
||||
54
pkg/time/tz/find_test.go
Normal file
54
pkg/time/tz/find_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package tz
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFind(t *testing.T) {
|
||||
t.Run("UTC", func(t *testing.T) {
|
||||
assert.Equal(t, time.UTC.String(), Find(time.UTC.String()).String())
|
||||
assert.Equal(t, time.UTC.String(), Find("Z").String())
|
||||
assert.Equal(t, time.UTC.String(), Find("UTC").String())
|
||||
assert.Equal(t, time.UTC, Find("UTC"))
|
||||
assert.Equal(t, "UTC", Find("0").String())
|
||||
assert.Equal(t, "UTC", Find("UTC+0").String())
|
||||
assert.Equal(t, "UTC", Find("UTC+00:00").String())
|
||||
})
|
||||
t.Run("GMT", func(t *testing.T) {
|
||||
assert.Equal(t, "GMT", Find("GMT").String())
|
||||
assert.Equal(t, "GMT", Find("Etc/GMT").String())
|
||||
})
|
||||
t.Run("Local", func(t *testing.T) {
|
||||
assert.Equal(t, "Local", Find("").String())
|
||||
assert.Equal(t, TimeLocal, Find(""))
|
||||
assert.Equal(t, "Local", Find("Local").String())
|
||||
assert.Equal(t, TimeLocal, Find("Local"))
|
||||
})
|
||||
t.Run("Berlin", func(t *testing.T) {
|
||||
assert.Equal(t, "Europe/Berlin", Find("Europe/Berlin").String())
|
||||
})
|
||||
t.Run("Offset", func(t *testing.T) {
|
||||
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 13:20:17 +00:00")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
utc, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 11:20:17 +00:00")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
timeZone := UtcOffset(utc, local, "")
|
||||
|
||||
assert.Equal(t, "UTC+2", timeZone)
|
||||
|
||||
loc := Find(timeZone)
|
||||
|
||||
assert.Equal(t, "UTC+2", loc.String())
|
||||
})
|
||||
}
|
||||
26
pkg/time/tz/local.go
Normal file
26
pkg/time/tz/local.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package tz
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IsLocal returns true if the time zone string represents Local time.
|
||||
func IsLocal(s string) bool {
|
||||
if s == Unknown {
|
||||
return true
|
||||
} else if len(s) != len(Local) {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(s, Local)
|
||||
}
|
||||
|
||||
// TruncateLocal changes the precision of Local Time to full seconds to avoid jitter.
|
||||
func TruncateLocal(t time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
return t
|
||||
}
|
||||
|
||||
return t.Truncate(time.Second)
|
||||
}
|
||||
36
pkg/time/tz/local_test.go
Normal file
36
pkg/time/tz/local_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package tz
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsLocal(t *testing.T) {
|
||||
t.Run("True", func(t *testing.T) {
|
||||
assert.True(t, IsLocal(Unknown))
|
||||
assert.True(t, IsLocal(Local))
|
||||
assert.True(t, IsLocal("local"))
|
||||
assert.True(t, IsLocal("LOCAL"))
|
||||
})
|
||||
t.Run("False", func(t *testing.T) {
|
||||
assert.False(t, IsLocal("utc"))
|
||||
assert.False(t, IsLocal(UTC))
|
||||
assert.False(t, IsLocal(GMT))
|
||||
assert.False(t, IsLocal(Zulu))
|
||||
})
|
||||
}
|
||||
|
||||
func TestTruncateLocal(t *testing.T) {
|
||||
now := time.Now().In(TimeLocal)
|
||||
ns := now.Nanosecond()
|
||||
|
||||
result := TruncateLocal(now)
|
||||
timeZone, _ := result.Zone()
|
||||
assert.Equal(t, Local, timeZone)
|
||||
assert.Equal(t, 0, result.Nanosecond())
|
||||
if ns > 0 {
|
||||
assert.NotEqual(t, ns, result.Nanosecond())
|
||||
}
|
||||
}
|
||||
32
pkg/time/tz/name.go
Normal file
32
pkg/time/tz/name.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package tz
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxLen specifies the maximum length of time zone strings.
|
||||
const MaxLen = 64
|
||||
|
||||
// Name normalizes the specified time zone string and returns it.
|
||||
func Name(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
// Detect and return standard time zones.
|
||||
if IsUTC(s) {
|
||||
return UTC
|
||||
} else if IsLocal(s) {
|
||||
return Local
|
||||
}
|
||||
|
||||
// Clip to max length.
|
||||
if len(s) > MaxLen {
|
||||
s = s[:MaxLen]
|
||||
}
|
||||
|
||||
// Handle time zone offset strings.
|
||||
if zone := NormalizeUtcOffset(s); zone != "" {
|
||||
return zone
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
42
pkg/time/tz/name_test.go
Normal file
42
pkg/time/tz/name_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package tz
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
t.Run("UTC", func(t *testing.T) {
|
||||
assert.Equal(t, UTC, Name("utc"))
|
||||
assert.Equal(t, UTC, Name("UTC"))
|
||||
assert.Equal(t, UTC, Name("Z"))
|
||||
assert.Equal(t, UTC, Name("Zulu"))
|
||||
assert.Equal(t, UTC, Name("UTC+0"))
|
||||
assert.Equal(t, UTC, Name("UTC-0"))
|
||||
assert.Equal(t, UTC, Name("UTC+00"))
|
||||
assert.Equal(t, UTC, Name("UTC-00"))
|
||||
assert.Equal(t, UTC, Name("UTC+00:00"))
|
||||
assert.Equal(t, UTC, Name("UTC-00:00"))
|
||||
assert.Equal(t, UTC, Name("Etc/UTC+0"))
|
||||
})
|
||||
t.Run("Local", func(t *testing.T) {
|
||||
assert.Equal(t, Local, Name(""))
|
||||
assert.Equal(t, Local, Name("local"))
|
||||
assert.Equal(t, Local, Name("Local"))
|
||||
assert.Equal(t, Local, Name("LOCAL"))
|
||||
})
|
||||
t.Run("GMT", func(t *testing.T) {
|
||||
assert.Equal(t, GMT, Name("gmt"))
|
||||
assert.Equal(t, GMT, Name("GMT"))
|
||||
assert.Equal(t, GMT, Name("GMT+0"))
|
||||
assert.Equal(t, AsiaKathmandu, Name("Etc/GMT+05:45"))
|
||||
assert.Equal(t, AsiaKathmandu, Name("GMT+05:45"))
|
||||
assert.Equal(t, "UTC+1", Name("Etc/GMT+01:00"))
|
||||
assert.Equal(t, "UTC+1", Name("GMT+1"))
|
||||
assert.Equal(t, "UTC+2", Name("GMT+2"))
|
||||
assert.Equal(t, "UTC+10", Name("Etc/GMT+10"))
|
||||
assert.Equal(t, "UTC-3", Name("GMT-3"))
|
||||
assert.Equal(t, "UTC-12", Name("Etc/GMT-12"))
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package txt
|
||||
package tz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,116 +7,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimeZone returns a time zone for the given UTC offset string.
|
||||
func TimeZone(offset string) *time.Location {
|
||||
if offset == "" || offset == time.Local.String() {
|
||||
// Local Time.
|
||||
return time.Local
|
||||
} else if offset == "UTC" || offset == "Z" {
|
||||
// Coordinated Universal Time.
|
||||
return time.UTC
|
||||
} else if seconds, err := TimeOffset(offset); err == nil {
|
||||
if h := seconds / 3600; h > 0 || h < 0 {
|
||||
return time.FixedZone(fmt.Sprintf("UTC%+d", h), seconds)
|
||||
}
|
||||
} else if zone, zoneErr := time.LoadLocation(offset); zoneErr == nil {
|
||||
return zone
|
||||
}
|
||||
|
||||
return time.FixedZone("GMT", 0)
|
||||
}
|
||||
|
||||
// NormalizeUtcOffset returns a normalized UTC time offset string.
|
||||
func NormalizeUtcOffset(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch s {
|
||||
case "-12", "-12:00", "UTC-12", "UTC-12:00":
|
||||
return "UTC-12"
|
||||
case "-11", "-11:00", "UTC-11", "UTC-11:00":
|
||||
return "UTC-11"
|
||||
case "-10", "-10:00", "UTC-10", "UTC-10:00":
|
||||
return "UTC-10"
|
||||
case "-9", "-09", "-09:00", "UTC-9", "UTC-09:00":
|
||||
return "UTC-9"
|
||||
case "-8", "-08", "-08:00", "UTC-8", "UTC-08:00":
|
||||
return "UTC-8"
|
||||
case "-7", "-07", "-07:00", "UTC-7", "UTC-07:00":
|
||||
return "UTC-7"
|
||||
case "-6", "-06", "-06:00", "UTC-6", "UTC-06:00":
|
||||
return "UTC-6"
|
||||
case "-5", "-05", "-05:00", "UTC-5", "UTC-05:00":
|
||||
return "UTC-5"
|
||||
case "-4", "-04", "-04:00", "UTC-4", "UTC-04:00":
|
||||
return "UTC-4"
|
||||
case "-3", "-03", "-03:00", "UTC-3", "UTC-03:00":
|
||||
return "UTC-3"
|
||||
case "-2", "-02", "-02:00", "UTC-2", "UTC-02:00":
|
||||
return "UTC-2"
|
||||
case "-1", "-01", "-01:00", "UTC-1", "UTC-01:00":
|
||||
return "UTC-1"
|
||||
case "Z", "UTC", "UTC+0", "UTC-0", "UTC+00:00", "UTC-00:00":
|
||||
return time.UTC.String()
|
||||
case "01:00", "+1", "+01", "+01:00", "UTC+1", "UTC+01:00":
|
||||
return "UTC+1"
|
||||
case "02:00", "+2", "+02", "+02:00", "UTC+2", "UTC+02:00":
|
||||
return "UTC+2"
|
||||
case "03:00", "+3", "+03", "+03:00", "UTC+3", "UTC+03:00":
|
||||
return "UTC+3"
|
||||
case "04:00", "+4", "+04", "+04:00", "UTC+4", "UTC+04:00":
|
||||
return "UTC+4"
|
||||
case "05:00", "+5", "+05", "+05:00", "UTC+5", "UTC+05:00":
|
||||
return "UTC+5"
|
||||
case "06:00", "+6", "+06", "+06:00", "UTC+6", "UTC+06:00":
|
||||
return "UTC+6"
|
||||
case "07:00", "+7", "+07", "+07:00", "UTC+7", "UTC+07:00":
|
||||
return "UTC+7"
|
||||
case "08:00", "+8", "+08", "+08:00", "UTC+8", "UTC+08:00":
|
||||
return "UTC+8"
|
||||
case "09:00", "+9", "+09", "+09:00", "UTC+9", "UTC+09:00":
|
||||
return "UTC+9"
|
||||
case "10:00", "+10", "+10:00", "UTC+10", "UTC+10:00":
|
||||
return "UTC+10"
|
||||
case "11:00", "+11", "+11:00", "UTC+11", "UTC+11:00":
|
||||
return "UTC+11"
|
||||
case "12:00", "+12", "+12:00", "UTC+12", "UTC+12:00":
|
||||
return "UTC+12"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// UtcOffset returns the time difference as UTC offset string.
|
||||
func UtcOffset(utc, local time.Time, offset string) string {
|
||||
if offset = NormalizeUtcOffset(offset); offset != "" {
|
||||
return offset
|
||||
}
|
||||
|
||||
if local.IsZero() || utc.IsZero() || local == utc {
|
||||
return ""
|
||||
}
|
||||
|
||||
d := local.Sub(utc).Hours()
|
||||
|
||||
// Return if time difference includes fractions of an hour.
|
||||
if math.Abs(d-float64(int64(d))) > 0.1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check if time difference is within expected range (hours).
|
||||
if h := int(d); h == 0 || h < -12 || h > 12 {
|
||||
return ""
|
||||
} else {
|
||||
return fmt.Sprintf("UTC%+d", h)
|
||||
}
|
||||
}
|
||||
|
||||
// TimeOffset returns the UTC time offset in seconds or an error if it is invalid.
|
||||
func TimeOffset(utcOffset string) (seconds int, err error) {
|
||||
// Offset returns the UTC time offset in seconds or an error if it is invalid.
|
||||
func Offset(utcOffset string) (seconds int, err error) {
|
||||
switch utcOffset {
|
||||
case "Z", "GMT", "Etc/GMT", "UTC", "UTC+0", "UTC-0", "UTC+00:00", "UTC-00:00":
|
||||
seconds = 0
|
||||
@@ -174,3 +66,106 @@ func TimeOffset(utcOffset string) (seconds int, err error) {
|
||||
|
||||
return seconds, nil
|
||||
}
|
||||
|
||||
// NormalizeUtcOffset returns a normalized UTC time offset string.
|
||||
func NormalizeUtcOffset(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch s {
|
||||
case "0", "Z", "UTC", "Etc/UTC", "Etc/UTC+0", "Etc/UTC+00", "Etc/UTC+00:00", "UTC+0", "UTC-0", "UTC+00", "UTC-00", "UTC+00:00", "UTC-00:00", "Zulu", "Etc/Zulu":
|
||||
return UTC
|
||||
case "Etc/GMT", "Etc/GMT+00:00", "Etc/GMT-00:00", "Etc/GMT+00", "Etc/GMT-00", "Etc/GMT+0", "GMT+0", "GMT", "gmt":
|
||||
return GMT
|
||||
case "Etc/GMT+01:00", "Etc/GMT+01", "GMT+1", "01:00", "+1", "+01", "+01:00", "UTC+1", "UTC+01:00":
|
||||
return "UTC+1"
|
||||
case "Etc/GMT+02:00", "Etc/GMT+02", "GMT+2", "02:00", "+2", "+02", "+02:00", "UTC+2", "UTC+02:00":
|
||||
return "UTC+2"
|
||||
case "Etc/GMT+03:00", "Etc/GMT+03", "GMT+3", "03:00", "+3", "+03", "+03:00", "UTC+3", "UTC+03:00":
|
||||
return "UTC+3"
|
||||
case "Etc/GMT+04:00", "Etc/GMT+04", "GMT+4", "04:00", "+4", "+04", "+04:00", "UTC+4", "UTC+04:00":
|
||||
return "UTC+4"
|
||||
case "Etc/GMT+05:00", "Etc/GMT+05", "GMT+5", "05:00", "+5", "+05", "+05:00", "UTC+5", "UTC+05:00":
|
||||
return "UTC+5"
|
||||
case "Etc/GMT+05:45", "GMT+05:45", "UTC+05:45", "Z+05:45":
|
||||
return AsiaKathmandu
|
||||
case "Etc/GMT+06:00", "Etc/GMT+06", "GMT+6", "06:00", "+6", "+06", "+06:00", "UTC+6", "UTC+06:00":
|
||||
return "UTC+6"
|
||||
case "Etc/GMT+07:00", "Etc/GMT+07", "GMT+7", "07:00", "+7", "+07", "+07:00", "UTC+7", "UTC+07:00":
|
||||
return "UTC+7"
|
||||
case "Etc/GMT+08:00", "Etc/GMT+08", "GMT+8", "08:00", "+8", "+08", "+08:00", "UTC+8", "UTC+08:00":
|
||||
return "UTC+8"
|
||||
case "Etc/GMT+09:00", "Etc/GMT+09", "GMT+9", "09:00", "+9", "+09", "+09:00", "UTC+9", "UTC+09:00":
|
||||
return "UTC+9"
|
||||
case "Etc/GMT+10:00", "Etc/GMT+10", "GMT+10", "10:00", "+10", "+10:00", "UTC+10", "UTC+10:00":
|
||||
return "UTC+10"
|
||||
case "Etc/GMT+11:00", "Etc/GMT+11", "GMT+11", "11:00", "+11", "+11:00", "UTC+11", "UTC+11:00":
|
||||
return "UTC+11"
|
||||
case "Etc/GMT+12:00", "Etc/GMT+12", "GMT+12", "12:00", "+12", "+12:00", "UTC+12", "UTC+12:00":
|
||||
return "UTC+12"
|
||||
case "Etc/GMT-12:00", "Etc/GMT-12", "GMT-12", "-12", "-12:00", "UTC-12", "UTC-12:00":
|
||||
return "UTC-12"
|
||||
case "Etc/GMT-11:00", "Etc/GMT-11", "GMT-11", "-11", "-11:00", "UTC-11", "UTC-11:00":
|
||||
return "UTC-11"
|
||||
case "Etc/GMT-10:00", "Etc/GMT-10", "GMT-10", "-10", "-10:00", "UTC-10", "UTC-10:00":
|
||||
return "UTC-10"
|
||||
case "Etc/GMT-09:00", "Etc/GMT-09", "GMT-9", "-9", "-09", "-09:00", "UTC-9", "UTC-09:00":
|
||||
return "UTC-9"
|
||||
case "Etc/GMT-08:00", "Etc/GMT-08", "GMT-8", "-8", "-08", "-08:00", "UTC-8", "UTC-08:00":
|
||||
return "UTC-8"
|
||||
case "Etc/GMT-07:00", "Etc/GMT-07", "GMT-7", "-7", "-07", "-07:00", "UTC-7", "UTC-07:00":
|
||||
return "UTC-7"
|
||||
case "Etc/GMT-06:00", "Etc/GMT-06", "GMT-6", "-6", "-06", "-06:00", "UTC-6", "UTC-06:00":
|
||||
return "UTC-6"
|
||||
case "Etc/GMT-05:00", "Etc/GMT-05", "GMT-5", "-5", "-05", "-05:00", "UTC-5", "UTC-05:00":
|
||||
return "UTC-5"
|
||||
case "Etc/GMT-04:00", "Etc/GMT-04", "GMT-4", "-4", "-04", "-04:00", "UTC-4", "UTC-04:00":
|
||||
return "UTC-4"
|
||||
case "Etc/GMT-03:00", "Etc/GMT-03", "GMT-3", "-3", "-03", "-03:00", "UTC-3", "UTC-03:00":
|
||||
return "UTC-3"
|
||||
case "Etc/GMT-02:00", "Etc/GMT-02", "GMT-2", "-2", "-02", "-02:00", "UTC-2", "UTC-02:00":
|
||||
return "UTC-2"
|
||||
case "Etc/GMT-01:00", "Etc/GMT-01", "GMT-1", "-1", "-01", "-01:00", "UTC-1", "UTC-01:00":
|
||||
return "UTC-1"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// UtcOffset returns the time difference as UTC offset string.
|
||||
func UtcOffset(utc, local time.Time, offset string) string {
|
||||
if offset = NormalizeUtcOffset(offset); offset != "" {
|
||||
return offset
|
||||
} else if utc.IsZero() || local == utc {
|
||||
return ""
|
||||
}
|
||||
|
||||
utc = utc.Truncate(time.Second)
|
||||
|
||||
if local.IsZero() {
|
||||
if _, sec := utc.Zone(); sec == 0 {
|
||||
return ""
|
||||
} else {
|
||||
return fmt.Sprintf("UTC%+d", sec/3600)
|
||||
}
|
||||
}
|
||||
|
||||
local = local.Truncate(time.Second)
|
||||
|
||||
d := local.Sub(utc).Hours()
|
||||
|
||||
// Return if time difference includes fractions of an hour.
|
||||
if math.Abs(d-float64(int64(d))) > 0.1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check if time difference is within expected range (hours).
|
||||
if h := int(d); h == 0 || h < -12 || h > 12 {
|
||||
return ""
|
||||
} else {
|
||||
return fmt.Sprintf("UTC%+d", h)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package txt
|
||||
package tz
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -7,44 +7,52 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTimeZone(t *testing.T) {
|
||||
t.Run("UTC", func(t *testing.T) {
|
||||
assert.Equal(t, time.UTC.String(), TimeZone(time.UTC.String()).String())
|
||||
assert.Equal(t, time.UTC.String(), TimeZone("Z").String())
|
||||
assert.Equal(t, time.UTC.String(), TimeZone("UTC").String())
|
||||
assert.Equal(t, time.UTC, TimeZone("UTC"))
|
||||
func TestOffset(t *testing.T) {
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
sec, err := Offset("UTC-2")
|
||||
assert.Equal(t, -2*3600, sec)
|
||||
assert.NoError(t, err)
|
||||
|
||||
sec, err = Offset("UTC")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.NoError(t, err)
|
||||
|
||||
sec, err = Offset("UTC+1")
|
||||
assert.Equal(t, 3600, sec)
|
||||
assert.NoError(t, err)
|
||||
|
||||
sec, err = Offset("UTC+2")
|
||||
assert.Equal(t, 2*3600, sec)
|
||||
assert.NoError(t, err)
|
||||
|
||||
sec, err = Offset("UTC+12")
|
||||
assert.Equal(t, 12*3600, sec)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
t.Run("GMT", func(t *testing.T) {
|
||||
assert.Equal(t, "GMT", TimeZone("0").String())
|
||||
assert.Equal(t, "GMT", TimeZone("UTC+0").String())
|
||||
assert.Equal(t, "GMT", TimeZone("UTC+00:00").String())
|
||||
})
|
||||
t.Run("Local", func(t *testing.T) {
|
||||
assert.Equal(t, "Local", TimeZone("").String())
|
||||
assert.Equal(t, time.Local, TimeZone(""))
|
||||
assert.Equal(t, "Local", TimeZone("Local").String())
|
||||
assert.Equal(t, time.Local, TimeZone("Local"))
|
||||
})
|
||||
t.Run("UTC+2", func(t *testing.T) {
|
||||
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 13:20:17 +00:00")
|
||||
t.Run("Invalid", func(t *testing.T) {
|
||||
sec, err := Offset("UTC-15")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.Error(t, err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sec, err = Offset("UTC--2")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.Error(t, err)
|
||||
|
||||
utc, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 11:20:17 +00:00")
|
||||
sec, err = Offset("UTC0")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.Error(t, err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sec, err = Offset("UTC1")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.Error(t, err)
|
||||
|
||||
timeZone := UtcOffset(utc, local, "")
|
||||
sec, err = Offset("UTC13")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.Error(t, err)
|
||||
|
||||
assert.Equal(t, "UTC+2", timeZone)
|
||||
|
||||
loc := TimeZone(timeZone)
|
||||
|
||||
assert.Equal(t, "UTC+2", loc.String())
|
||||
sec, err = Offset("UTC+13")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -208,53 +216,16 @@ func TestUtcOffset(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "", UtcOffset(utc, local, ""))
|
||||
})
|
||||
}
|
||||
t.Run("UTC+02:00", func(t *testing.T) {
|
||||
utc, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 13:50:17 +02:00")
|
||||
|
||||
func TestTimeOffset(t *testing.T) {
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
sec, err := TimeOffset("UTC-2")
|
||||
assert.Equal(t, -2*3600, sec)
|
||||
assert.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sec, err = TimeOffset("UTC")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.NoError(t, err)
|
||||
result := UtcOffset(utc, time.Time{}, "")
|
||||
|
||||
sec, err = TimeOffset("UTC+1")
|
||||
assert.Equal(t, 3600, sec)
|
||||
assert.NoError(t, err)
|
||||
|
||||
sec, err = TimeOffset("UTC+2")
|
||||
assert.Equal(t, 2*3600, sec)
|
||||
assert.NoError(t, err)
|
||||
|
||||
sec, err = TimeOffset("UTC+12")
|
||||
assert.Equal(t, 12*3600, sec)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "UTC+2", result)
|
||||
})
|
||||
t.Run("Invalid", func(t *testing.T) {
|
||||
sec, err := TimeOffset("UTC-15")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.Error(t, err)
|
||||
|
||||
sec, err = TimeOffset("UTC--2")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.Error(t, err)
|
||||
|
||||
sec, err = TimeOffset("UTC0")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.Error(t, err)
|
||||
|
||||
sec, err = TimeOffset("UTC1")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.Error(t, err)
|
||||
|
||||
sec, err = TimeOffset("UTC13")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.Error(t, err)
|
||||
|
||||
sec, err = TimeOffset("UTC+13")
|
||||
assert.Equal(t, 0, sec)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
29
pkg/time/tz/position.go
Normal file
29
pkg/time/tz/position.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package tz
|
||||
|
||||
import (
|
||||
"github.com/ugjka/go-tz/v2"
|
||||
)
|
||||
|
||||
// Position returns the time zone for the given latitude and longitude.
|
||||
func Position(lat, lng float64) (name string) {
|
||||
if lat == 0.0 || lng == 0.0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
zones, err := tz.GetZone(tz.Point{
|
||||
Lat: lat,
|
||||
Lon: lng,
|
||||
})
|
||||
|
||||
if err != nil || len(zones) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
name = Name(zones[0])
|
||||
|
||||
if name == UTC || name == Local {
|
||||
return ""
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
16
pkg/time/tz/strip.go
Normal file
16
pkg/time/tz/strip.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package tz
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Strip removes the time zone from a time.
|
||||
func Strip(t time.Time) (result time.Time) {
|
||||
if t.IsZero() {
|
||||
return t
|
||||
}
|
||||
|
||||
result, _ = time.ParseInLocation("2006:01:02 15:04:05", t.Format("2006:01:02 15:04:05"), time.UTC)
|
||||
|
||||
return result
|
||||
}
|
||||
10
pkg/time/tz/time.go
Normal file
10
pkg/time/tz/time.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package tz
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
TimeUTC = time.UTC
|
||||
TimeLocal = time.FixedZone(Local, 0)
|
||||
)
|
||||
26
pkg/time/tz/utc.go
Normal file
26
pkg/time/tz/utc.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package tz
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IsUTC returns true if the time zone string represents Universal Coordinated Time (UTC).
|
||||
func IsUTC(s string) bool {
|
||||
if s == Unknown || len(s) > 7 {
|
||||
return false
|
||||
}
|
||||
|
||||
s = strings.ToUpper(s)
|
||||
|
||||
return s == UTC || s == Zulu || s == "ZULU" || s == "ETC/UTC"
|
||||
}
|
||||
|
||||
// TruncateUTC sets the time zone to UTC and changes the precision to seconds.
|
||||
func TruncateUTC(t time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
return t
|
||||
}
|
||||
|
||||
return t.UTC().Truncate(time.Second)
|
||||
}
|
||||
37
pkg/time/tz/utc_test.go
Normal file
37
pkg/time/tz/utc_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package tz
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsUTC(t *testing.T) {
|
||||
t.Run("True", func(t *testing.T) {
|
||||
assert.True(t, IsUTC("utc"))
|
||||
assert.True(t, IsUTC("UTC"))
|
||||
assert.True(t, IsUTC("Z"))
|
||||
assert.True(t, IsUTC("Zulu"))
|
||||
})
|
||||
t.Run("False", func(t *testing.T) {
|
||||
assert.False(t, IsUTC(""))
|
||||
assert.False(t, IsUTC("GMT"))
|
||||
assert.False(t, IsUTC("local"))
|
||||
assert.False(t, IsUTC("Local"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestTruncateUTC(t *testing.T) {
|
||||
now := time.Now().In(TimeLocal)
|
||||
ns := now.Nanosecond()
|
||||
|
||||
result := TruncateUTC(now)
|
||||
assert.Equal(t, TimeUTC, result.Location())
|
||||
timeZone, _ := result.Zone()
|
||||
assert.Equal(t, UTC, timeZone)
|
||||
assert.Equal(t, 0, result.Nanosecond())
|
||||
if ns > 0 {
|
||||
assert.NotEqual(t, ns, result.Nanosecond())
|
||||
}
|
||||
}
|
||||
25
pkg/time/tz/zone.go
Normal file
25
pkg/time/tz/zone.go
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Package tz provides time zone constants and functions.
|
||||
|
||||
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||
<https://docs.photoprism.app/license/agpl>
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||
which describe how our Brand Assets may be used:
|
||||
<https://www.photoprism.app/trademark>
|
||||
|
||||
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||
want to support our work, or just want to say hello.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
*/
|
||||
package tz
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
)
|
||||
|
||||
// regex tester: https://regoio.herokuapp.com/
|
||||
@@ -152,23 +154,13 @@ func ParseTime(s, timeZone string) (t time.Time) {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// Default to UTC.
|
||||
tz := time.UTC
|
||||
|
||||
// Local time zone currently not supported (undefined).
|
||||
if timeZone == time.Local.String() {
|
||||
timeZone = ""
|
||||
}
|
||||
|
||||
// Set time zone.
|
||||
loc := TimeZone(timeZone)
|
||||
loc := tz.Find(timeZone)
|
||||
zone := tz.TimeUTC
|
||||
|
||||
// Location found?
|
||||
if loc != nil && timeZone != "" && tz != time.Local {
|
||||
tz = loc
|
||||
timeZone = tz.String()
|
||||
} else {
|
||||
timeZone = ""
|
||||
if timeZone != "" {
|
||||
zone = loc
|
||||
timeZone = loc.String()
|
||||
}
|
||||
|
||||
// Does the timestamp contain a time zone offset?
|
||||
@@ -181,19 +173,19 @@ func ParseTime(s, timeZone string) (t time.Time) {
|
||||
// Offset timezone name example: UTC+03:30
|
||||
if z == "+" {
|
||||
// Positive offset relative to UTC.
|
||||
tz = time.FixedZone(fmt.Sprintf("UTC+%02d:%02d", zh, zm), offset)
|
||||
zone = time.FixedZone(fmt.Sprintf("UTC+%02d:%02d", zh, zm), offset)
|
||||
} else if z == "-" {
|
||||
// Negative offset relative to UTC.
|
||||
tz = time.FixedZone(fmt.Sprintf("UTC-%02d:%02d", zh, zm), -1*offset)
|
||||
zone = time.FixedZone(fmt.Sprintf("UTC-%02d:%02d", zh, zm), -1*offset)
|
||||
}
|
||||
}
|
||||
|
||||
var nsec int
|
||||
var ns int
|
||||
|
||||
if subsec := m[v["subsec"]]; subsec != "" {
|
||||
nsec = Int(subsec + strings.Repeat("0", 9-len(subsec)))
|
||||
if subSec := m[v["subsec"]]; subSec != "" {
|
||||
ns = Int(subSec + strings.Repeat("0", 9-len(subSec)))
|
||||
} else {
|
||||
nsec = 0
|
||||
ns = 0
|
||||
}
|
||||
|
||||
// Create rounded timestamp from parsed input values.
|
||||
@@ -215,10 +207,10 @@ func ParseTime(s, timeZone string) (t time.Time) {
|
||||
IntVal(m[v["h"]], 0, 23, 0),
|
||||
IntVal(m[v["m"]], 0, 59, 0),
|
||||
IntVal(m[v["s"]], 0, 59, 0),
|
||||
nsec,
|
||||
tz)
|
||||
ns,
|
||||
zone)
|
||||
|
||||
if timeZone != "" && loc != nil && loc != tz {
|
||||
if timeZone != "" && timeZone != tz.Local && loc != zone {
|
||||
return t.In(loc)
|
||||
}
|
||||
|
||||
|
||||
@@ -216,28 +216,28 @@ func TestParseTime(t *testing.T) {
|
||||
result := ParseTime("2020-01-30_09-57-18", "")
|
||||
assert.Equal(t, "2020-01-30 09:57:18 +0000 UTC", result.String())
|
||||
})
|
||||
t.Run("EuropeBerlin/2016:06:28 09:45:49+10:00ABC", func(t *testing.T) {
|
||||
t.Run("BerlinOffset", func(t *testing.T) {
|
||||
result := ParseTime("2016:06:28 09:45:49+10:00ABC", "Europe/Berlin")
|
||||
assert.Equal(t, "2016-06-28 01:45:49 +0200 CEST", result.String())
|
||||
})
|
||||
t.Run("EuropeBerlin/ 2016:06:28 09:45:49-01:30ABC", func(t *testing.T) {
|
||||
t.Run("BerlinNegativeOffset", func(t *testing.T) {
|
||||
result := ParseTime(" 2016:06:28 09:45:49-01:30ABC", "Europe/Berlin")
|
||||
assert.Equal(t, "2016-06-28 13:15:49 +0200 CEST", result.String())
|
||||
})
|
||||
t.Run("EuropeBerlin/2012:08:08 22:07:18", func(t *testing.T) {
|
||||
t.Run("BerlinNoOffset", func(t *testing.T) {
|
||||
result := ParseTime("2012:08:08 22:07:18", "Europe/Berlin")
|
||||
assert.Equal(t, "2012-08-08 22:07:18 +0200 CEST", result.String())
|
||||
})
|
||||
t.Run("EuropeBerlin/2020-01-30_09-57-18", func(t *testing.T) {
|
||||
t.Run("BerlinUnderscore", func(t *testing.T) {
|
||||
result := ParseTime("2020-01-30_09-57-18", "Europe/Berlin")
|
||||
assert.Equal(t, "2020-01-30 09:57:18 +0100 CET", result.String())
|
||||
})
|
||||
t.Run("EuropeBerlin/2020-10-17-48-24.950488", func(t *testing.T) {
|
||||
t.Run("UtcSubSec", func(t *testing.T) {
|
||||
result := ParseTime("2020:10:17 17:48:24.9508123", "UTC")
|
||||
assert.Equal(t, "2020-10-17 17:48:24.9508123 +0000 UTC", result.UTC().String())
|
||||
assert.Equal(t, "2020-10-17 17:48:24.9508123", result.Format("2006-01-02 15:04:05.999999999"))
|
||||
})
|
||||
t.Run("UTC/0000:00:00 00:00:00", func(t *testing.T) {
|
||||
t.Run("UtcUnknown", func(t *testing.T) {
|
||||
assert.True(t, ParseTime("0000:00:00 00:00:00", "UTC").IsZero())
|
||||
})
|
||||
t.Run("2022-09-03T17:48:26-07:00", func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user