Metadata: Improve handling of local time values #3780

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-10-21 02:31:27 +02:00
parent 60efc86649
commit 67bd054f7b
6 changed files with 179 additions and 163 deletions

View file

@ -2710,19 +2710,19 @@
}
},
"node_modules/@eslint/js": {
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.51.0.tgz",
"integrity": "sha512-HxjQ8Qn+4SI3/AFv6sOrDB+g6PpUTDwSJiQqOrnneEk8L71161srI9gjzzZvYVbzHiVg/BvcH95+cK/zfIt4pg==",
"version": "8.52.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.52.0.tgz",
"integrity": "sha512-mjZVbpaeMZludF2fsWLD0Z9gCref1Tk4i9+wddjRvpUNqqcndPkBD09N/Mapey0b3jaXbLm2kICwFv2E64QinA==",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.12",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.12.tgz",
"integrity": "sha512-NlGesA1usRNn6ctHCZ21M4/dKPgW9Nn1FypRdIKKgZOKzkVV4T1FlK5mBiLhHBCDmEbdQG0idrcXlbZfksJ+RA==",
"version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
"integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==",
"dependencies": {
"@humanwhocodes/object-schema": "^2.0.0",
"@humanwhocodes/object-schema": "^2.0.1",
"debug": "^4.1.1",
"minimatch": "^3.0.5"
},
@ -2743,9 +2743,9 @@
}
},
"node_modules/@humanwhocodes/object-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.0.tgz",
"integrity": "sha512-9S9QrXY2K0L4AGDcSgTi9vgiCcG8VcBv4Mp7/1hDPYoswIy6Z6KO5blYto82BT8M0MZNRWmCFLpCs3HlpYGGdw=="
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz",
"integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw=="
},
"node_modules/@ioredis/commands": {
"version": "1.2.0",
@ -2990,9 +2990,9 @@
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "19.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.2.tgz",
"integrity": "sha512-C2JAk64XUz9v78+bpyTk1zvgjjnDsB8CCjNumyAYdWK2dvdDtILzh1AGBMdS/llX3KaHjGYxAE5wOwfdwq4Pog==",
"version": "19.3.3",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz",
"integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
@ -3216,37 +3216,42 @@
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.2.tgz",
"integrity": "sha512-5qcvofLPbfjmBfKaLfj/+f+Sbd6pN4zl7w7VSVI5uz7m9QZTuB2aZAa2uo1wHFBNN2x6g/SoTkXmd8mQnQF2Cw=="
},
"node_modules/@ungap/structured-clone": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
},
"node_modules/@vue/compiler-core": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.5.tgz",
"integrity": "sha512-S8Ma+eICI40Y4UotR+iKR729Bma+wERn/xLc+Jz203s5WIW1Sx3qoiONqXGg3Q4vBMa+QHDncULya19ZSJuhog==",
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.6.tgz",
"integrity": "sha512-2JNjemwaNwf+MkkatATVZi7oAH1Hx0B04DdPH3ZoZ8vKC1xZVP7nl4HIsk8XYd3r+/52sqqoz9TWzYc3yE9dqA==",
"dependencies": {
"@babel/parser": "^7.23.0",
"@vue/shared": "3.3.5",
"@vue/shared": "3.3.6",
"estree-walker": "^2.0.2",
"source-map-js": "^1.0.2"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.5.tgz",
"integrity": "sha512-dxt6QntN9T/NtnV6Pz+/nmcoo3ULnsYCnRpvEyY73wbk1tzzx7dnwngUN1cXkyGNu9c3UE7llhq/5T54lKwyhQ==",
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.6.tgz",
"integrity": "sha512-1MxXcJYMHiTPexjLAJUkNs/Tw2eDf2tY3a0rL+LfuWyiKN2s6jvSwywH3PWD8bKICjfebX3GWx2Os8jkRDq3Ng==",
"dependencies": {
"@vue/compiler-core": "3.3.5",
"@vue/shared": "3.3.5"
"@vue/compiler-core": "3.3.6",
"@vue/shared": "3.3.6"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.5.tgz",
"integrity": "sha512-M6ys4iReSbrF4NTcMCnJiBioCpzXjfkfXwkdziknRyps+pG0DkwpDfQT7zQ0q91/rCR/Ejz64b5H6C4HBhX41w==",
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.6.tgz",
"integrity": "sha512-/Kms6du2h1VrXFreuZmlvQej8B1zenBqIohP0690IUBkJjsFvJxY0crcvVRJ0UhMgSR9dewB+khdR1DfbpArJA==",
"dependencies": {
"@babel/parser": "^7.23.0",
"@vue/compiler-core": "3.3.5",
"@vue/compiler-dom": "3.3.5",
"@vue/compiler-ssr": "3.3.5",
"@vue/reactivity-transform": "3.3.5",
"@vue/shared": "3.3.5",
"@vue/compiler-core": "3.3.6",
"@vue/compiler-dom": "3.3.6",
"@vue/compiler-ssr": "3.3.6",
"@vue/reactivity-transform": "3.3.6",
"@vue/shared": "3.3.6",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.5",
"postcss": "^8.4.31",
@ -3254,12 +3259,12 @@
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.5.tgz",
"integrity": "sha512-v7p2XuEpOcgjd6c49NqOnq3UTJOv5Uo9tirOyGnEadwxTov2O1J3/TUt4SgAAnwA+9gcUyH5c3lIOFsBe+UIyw==",
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.6.tgz",
"integrity": "sha512-QTIHAfDCHhjXlYGkUg5KH7YwYtdUM1vcFl/FxFDlD6d0nXAmnjizka3HITp8DGudzHndv2PjKVS44vqqy0vP4w==",
"dependencies": {
"@vue/compiler-dom": "3.3.5",
"@vue/shared": "3.3.5"
"@vue/compiler-dom": "3.3.6",
"@vue/shared": "3.3.6"
}
},
"node_modules/@vue/component-compiler-utils": {
@ -3331,26 +3336,26 @@
"integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A=="
},
"node_modules/@vue/reactivity-transform": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.5.tgz",
"integrity": "sha512-OhpBD1H32pIapRzqy31hWwTFLf9STP+0uk5bVOQWXACTa2Rt/RPhvX4zixbPgMGo6iP+S+tFpZzUdcG8AASn8A==",
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.6.tgz",
"integrity": "sha512-RlJl4dHfeO7EuzU1iJOsrlqWyJfHTkJbvYz/IOJWqu8dlCNWtxWX377WI0VsbAgBizjwD+3ZjdnvSyyFW1YVng==",
"dependencies": {
"@babel/parser": "^7.23.0",
"@vue/compiler-core": "3.3.5",
"@vue/shared": "3.3.5",
"@vue/compiler-core": "3.3.6",
"@vue/shared": "3.3.6",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.5"
}
},
"node_modules/@vue/shared": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.5.tgz",
"integrity": "sha512-oNJN1rCtkqm1cIxU1BuZVEVRWIp4DhaxXucEzzZ/iDKHP71ZxhkBPNK+URySiECH6aiOZzC60PS2bd6JFznvNA=="
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.6.tgz",
"integrity": "sha512-Xno5pEqg8SVhomD0kTSmfh30ZEmV/+jZtyh39q6QflrjdJCXah5lrnOLi9KB6a5k5aAHXMXjoMnxlzUkCNfWLQ=="
},
"node_modules/@vvo/tzdb": {
"version": "6.108.0",
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.108.0.tgz",
"integrity": "sha512-/UI2yKYNlcPVsVajMNcLfcsZgD+TtmE9hsN+3JTrk8N4/Kwlr35SqMOZuSU7lwWG+PvWmWKs51f2SMM0JGWxww=="
"version": "6.109.0",
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.109.0.tgz",
"integrity": "sha512-HFE2m2YIiW0POGepiHAPYlqzv9YZxc96faxVH0UOen4Djvl+l3fSVeeTgQRCOCy+aKLtqALthVrVgt8BOlWkmg=="
},
"node_modules/@webassemblyjs/ast": {
"version": "1.11.6",
@ -5809,9 +5814,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.4.561",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.561.tgz",
"integrity": "sha512-eS5t4ulWOBfVHdq9SW2dxEaFarj1lPjvJ8PaYMOjY0DecBaj/t4ARziL2IPpDr4atyWwjLFGQ2vo/VCgQFezVQ=="
"version": "1.4.563",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.563.tgz",
"integrity": "sha512-dg5gj5qOgfZNkPNeyKBZQAQitIQ/xwfIDmEQJHCbXaD9ebTZxwJXUsDYcBlAvZGZLi+/354l35J1wkmP6CqYaw=="
},
"node_modules/emoji-regex": {
"version": "8.0.0",
@ -6022,17 +6027,18 @@
}
},
"node_modules/eslint": {
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.51.0.tgz",
"integrity": "sha512-2WuxRZBrlwnXi+/vFSJyjMqrNjtJqiasMzehF0shoLaW7DzS3/9Yvrmq5JiT66+pNjiX4UBnLDiKHcWAr/OInA==",
"version": "8.52.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.52.0.tgz",
"integrity": "sha512-zh/JHnaixqHZsolRB/w9/02akBk9EPrOs9JwcTP2ek7yL5bVvXuRariiaAjjoJ5DvuwQ1WAE/HsMz+w17YgBCg==",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^2.1.2",
"@eslint/js": "8.51.0",
"@humanwhocodes/config-array": "^0.11.11",
"@eslint/js": "8.52.0",
"@humanwhocodes/config-array": "^0.11.13",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
"@ungap/structured-clone": "^1.2.0",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.2",
@ -7460,19 +7466,19 @@
"integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ=="
},
"node_modules/flow-parser": {
"version": "0.219.2",
"resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.219.2.tgz",
"integrity": "sha512-OqzmNECXX85x/5L/OP9TfHErdDoSUoKR4y1sTTy/A5K2arwl7s5EmX0XTkkcJPlCAHYkElWj5Se+ZwNN/6ry2Q==",
"version": "0.219.3",
"resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.219.3.tgz",
"integrity": "sha512-dyPC0+TwAcBMQ1IZhSpj91mxZ31AI9FJ3q/ZMt8kdKaITnDCGmyUyWOwUfAKBVLrUTkdaTfpla0muhwOGY+dXw==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/flow-remove-types": {
"version": "2.219.2",
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.219.2.tgz",
"integrity": "sha512-g0BFqtf882YOntBvMSXXz7qTEsIKuLBefzk0mLy3PkRDDty1jYmxAorDg9xY7ydWNyONohNaeNVg4x33wGpWlw==",
"version": "2.219.3",
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.219.3.tgz",
"integrity": "sha512-xYAJIcShkcYALDbMbGGDqOgZTEdH56QbF6M6pOqU1Nww9m1U7y1PJpBXkQIlOolvqZyaEy/gDr0gNweOOspJyg==",
"dependencies": {
"flow-parser": "^0.219.2",
"flow-parser": "^0.219.3",
"pirates": "^3.0.2",
"vlq": "^0.2.1"
},

View file

@ -12,8 +12,6 @@ import {
} from "model/photo";
export const UtcOffsets = [
{ ID: "UTC-14", Name: "UTC-14:00" },
{ ID: "UTC-13", Name: "UTC-13:00" },
{ ID: "UTC-12", Name: "UTC-12:00" },
{ ID: "UTC-11", Name: "UTC-11:00" },
{ ID: "UTC-10", Name: "UTC-10:00" },

View file

@ -9,8 +9,8 @@ describe("options/options", () => {
const timezones = options.TimeZones();
assert.equal(timezones[0].ID, "");
assert.equal(timezones[0].Name, "Local Time");
assert.equal(timezones[1].ID, "UTC-14");
assert.equal(timezones[1].Name, "UTC-14:00");
assert.equal(timezones[1].ID, "UTC-12");
assert.equal(timezones[1].Name, "UTC-12:00");
});
it("should get days", () => {

View file

@ -243,49 +243,49 @@ 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, err := tz.GetZone(tz.Point{
zones, zoneErr := tz.GetZone(tz.Point{
Lat: float64(data.Lat),
Lon: float64(data.Lng),
})
if err == nil && len(zones) > 0 {
if zoneErr == nil && len(zones) > 0 {
data.TimeZone = zones[0]
}
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 tl, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); err == nil {
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.TakenAt = tl.Truncate(time.Second).UTC()
} else {
log.Errorf("metadata: %s (exiftool)", err.Error()) // this should never happen
log.Errorf("metadata: %s (exiftool)", parseErr.Error()) // this should never happen
}
} else if !data.TakenAt.IsZero() {
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 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)", parseErr.Error()) // this should never happen
}
}
} else if hasTimeOffset {
if localUtc, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), time.UTC); err == nil {
if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil {
data.TakenAtLocal = localUtc.Truncate(time.Second).UTC()
}
data.TakenAt = data.TakenAt.Truncate(time.Second).UTC()
}
// Default to UTC offset time zone?
if data.TimeZone != "" && data.TimeZone != "UTC" || data.TakenAtLocal.IsZero() || data.TakenAt.IsZero() {
// Set UTC offset as time zone?
if data.TimeZone != "" && data.TimeZone != "UTC" || data.TakenAt.IsZero() {
// Don't change existing time zone.
} else if z := txt.UtcOffset(data.TakenAtLocal, data.TakenAt, data.TimeOffset); z != "" {
data.TimeZone = z
log.Infof("metadata: %s has time offset %s (exiftool)", logName, clean.Log(data.TimeZone))
} else if utcOffset := txt.UtcOffset(data.TakenAtLocal, data.TakenAt, data.TimeOffset); utcOffset != "" {
data.TimeZone = utcOffset
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))
}

View file

@ -9,30 +9,19 @@ import (
// TimeZone returns a time zone for the given UTC offset string.
func TimeZone(offset string) *time.Location {
if IsUtcOffset(offset) {
sec := TimeOffset(offset)
if sec == 0 {
return time.UTC
if offset == "" {
// Local time.
} else if offset == "UTC" || offset == "Z" {
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)
}
return time.FixedZone(fmt.Sprintf("UTC%+d", sec/3600), sec)
} else if location, err := time.LoadLocation(offset); err == nil {
return location
} else if zone, zoneErr := time.LoadLocation(offset); zoneErr == nil {
return zone
}
return nil
}
// IsUtcOffset checks if the string is a valid UTC time offset.
func IsUtcOffset(s string) bool {
if l := len(s); l < 3 || l > 6 {
return false
} else if s == "UTC" {
return true
} else if !strings.HasPrefix(s, "UTC") {
return false
}
return TimeOffset(s) != 0
return time.FixedZone("", 0)
}
// NormalizeUtcOffset returns a normalized UTC time offset string.
@ -68,7 +57,7 @@ func NormalizeUtcOffset(s string) string {
return "UTC-2"
case "-1", "-01", "-01:00", "UTC-1", "UTC-01:00":
return "UTC-1"
case "+0", "+00", "00:00", "+00:00", "-00:00", "Z", "Z00:00", "UTC", "UTC+0", "UTC-0", "UTC+00:00", "UTC-00:00":
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"
@ -117,66 +106,69 @@ func UtcOffset(local, utc time.Time, offset string) string {
}
// Check if time difference is within expected range (hours).
if d < -12 || d > 12 {
if h := int(d); h == 0 || h < -12 || h > 12 {
return ""
} else if d == 0 {
return time.UTC.String()
} else {
return fmt.Sprintf("UTC%+d", h)
}
return fmt.Sprintf("UTC%+d", int(d))
}
func TimeOffset(s string) (seconds int) {
switch s {
// TimeOffset returns the UTC time offset in seconds or an error if it is invalid.
func TimeOffset(utcOffset string) (seconds int, err error) {
switch utcOffset {
case "-12", "-12:00", "UTC-12", "UTC-12:00":
return -12 * 3600
seconds = -12 * 3600
case "-11", "-11:00", "UTC-11", "UTC-11:00":
return -11 * 3600
seconds = -11 * 3600
case "-10", "-10:00", "UTC-10", "UTC-10:00":
return -10 * 3600
seconds = -10 * 3600
case "-9", "-09", "-09:00", "UTC-9", "UTC-09:00":
return -9 * 3600
seconds = -9 * 3600
case "-8", "-08", "-08:00", "UTC-8", "UTC-08:00":
return -8 * 3600
seconds = -8 * 3600
case "-7", "-07", "-07:00", "UTC-7", "UTC-07:00":
return -7 * 3600
seconds = -7 * 3600
case "-6", "-06", "-06:00", "UTC-6", "UTC-06:00":
return -6 * 3600
seconds = -6 * 3600
case "-5", "-05", "-05:00", "UTC-5", "UTC-05:00":
return -5 * 3600
seconds = -5 * 3600
case "-4", "-04", "-04:00", "UTC-4", "UTC-04:00":
return -4 * 3600
seconds = -4 * 3600
case "-3", "-03", "-03:00", "UTC-3", "UTC-03:00":
return -3 * 3600
seconds = -3 * 3600
case "-2", "-02", "-02:00", "UTC-2", "UTC-02:00":
return -2 * 3600
seconds = -2 * 3600
case "-1", "-01", "-01:00", "UTC-1", "UTC-01:00":
return -1 * 3600
seconds = -1 * 3600
case "01:00", "+1", "+01", "+01:00", "UTC+1", "UTC+01:00":
return 1 * 3600
seconds = 1 * 3600
case "02:00", "+2", "+02", "+02:00", "UTC+2", "UTC+02:00":
return 2 * 3600
seconds = 2 * 3600
case "03:00", "+3", "+03", "+03:00", "UTC+3", "UTC+03:00":
return 3 * 3600
seconds = 3 * 3600
case "04:00", "+4", "+04", "+04:00", "UTC+4", "UTC+04:00":
return 4 * 3600
seconds = 4 * 3600
case "05:00", "+5", "+05", "+05:00", "UTC+5", "UTC+05:00":
return 5 * 3600
seconds = 5 * 3600
case "06:00", "+6", "+06", "+06:00", "UTC+6", "UTC+06:00":
return 6 * 3600
seconds = 6 * 3600
case "07:00", "+7", "+07", "+07:00", "UTC+7", "UTC+07:00":
return 7 * 3600
seconds = 7 * 3600
case "08:00", "+8", "+08", "+08:00", "UTC+8", "UTC+08:00":
return 8 * 3600
seconds = 8 * 3600
case "09:00", "+9", "+09", "+09:00", "UTC+9", "UTC+09:00":
return 9 * 3600
seconds = 9 * 3600
case "10:00", "+10", "+10:00", "UTC+10", "UTC+10:00":
return 10 * 3600
seconds = 10 * 3600
case "11:00", "+11", "+11:00", "UTC+11", "UTC+11:00":
return 11 * 3600
seconds = 11 * 3600
case "12:00", "+12", "+12:00", "UTC+12", "UTC+12:00":
return 12 * 3600
seconds = 12 * 3600
case "Z", "UTC", "UTC+0", "UTC-0", "UTC+00:00", "UTC-00:00":
seconds = 0
default:
return 0
return 0, fmt.Errorf("invalid UTC offset")
}
return seconds, nil
}

View file

@ -10,6 +10,14 @@ import (
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())
})
t.Run("LocalTime", func(t *testing.T) {
assert.Equal(t, "", TimeZone("").String())
assert.Equal(t, "", TimeZone("0").String())
assert.Equal(t, "", TimeZone("UTC+0").String())
assert.Equal(t, "", TimeZone("UTC+00:00").String())
})
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")
@ -34,24 +42,6 @@ func TestTimeZone(t *testing.T) {
})
}
func TestIsUtcOffset(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
assert.Equal(t, true, IsUtcOffset("UTC-2"))
assert.Equal(t, true, IsUtcOffset("UTC"))
assert.Equal(t, true, IsUtcOffset("UTC+1"))
assert.Equal(t, true, IsUtcOffset("UTC+2"))
assert.Equal(t, true, IsUtcOffset("UTC+12"))
})
t.Run("Invalid", func(t *testing.T) {
assert.Equal(t, false, IsUtcOffset("UTC-15"))
assert.Equal(t, false, IsUtcOffset("UTC-14"))
assert.Equal(t, false, IsUtcOffset("UTC--2"))
assert.Equal(t, false, IsUtcOffset("UTC1"))
assert.Equal(t, false, IsUtcOffset("UTC13"))
assert.Equal(t, false, IsUtcOffset("UTC+13"))
})
}
func TestNormalizeUtcOffset(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
assert.Equal(t, "UTC-2", NormalizeUtcOffset("UTC-2"))
@ -112,8 +102,8 @@ func TestUtcOffset(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "UTC", UtcOffset(local, utc, "00:00"))
assert.Equal(t, "UTC", UtcOffset(local, utc, "+00:00"))
assert.Equal(t, "", UtcOffset(local, utc, "00:00"))
assert.Equal(t, "", UtcOffset(local, utc, "+00:00"))
assert.Equal(t, "UTC", UtcOffset(local, utc, "Z"))
})
t.Run("UTC+2", func(t *testing.T) {
@ -216,19 +206,49 @@ func TestUtcOffset(t *testing.T) {
func TestTimeOffset(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
assert.Equal(t, -2*3600, TimeOffset("UTC-2"))
assert.Equal(t, 0, TimeOffset("UTC"))
assert.Equal(t, 3600, TimeOffset("UTC+1"))
assert.Equal(t, 2*3600, TimeOffset("UTC+2"))
assert.Equal(t, 12*3600, TimeOffset("UTC+12"))
sec, err := TimeOffset("UTC-2")
assert.Equal(t, -2*3600, sec)
assert.NoError(t, err)
sec, err = TimeOffset("UTC")
assert.Equal(t, 0, sec)
assert.NoError(t, err)
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)
})
t.Run("Invalid", func(t *testing.T) {
assert.Equal(t, 0, TimeOffset("UTC-15"))
assert.Equal(t, 0, TimeOffset("UTC-14"))
assert.Equal(t, 0, TimeOffset("UTC--2"))
assert.Equal(t, 0, TimeOffset("UTC0"))
assert.Equal(t, 0, TimeOffset("UTC1"))
assert.Equal(t, 0, TimeOffset("UTC13"))
assert.Equal(t, 0, TimeOffset("UTC+13"))
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)
})
}