Metadata: Move timezone related functions to /pkg/time/tz #4622 #4946

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-04-23 12:50:12 +02:00
parent 598be273aa
commit 5cd2c25489
32 changed files with 693 additions and 358 deletions

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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{

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}
})

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"))
})
}

View File

@@ -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)
}
}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -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)
}

View File

@@ -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) {