Metadata: Use UTC offset if actual time zone is unknown #3780

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-10-21 00:11:11 +02:00
parent 54f281a425
commit 60efc86649
22 changed files with 705 additions and 39 deletions

View File

@ -11,11 +11,38 @@ import {
MediaRaw,
} 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" },
{ ID: "UTC-9", Name: "UTC-09:00" },
{ ID: "UTC-8", Name: "UTC-08:00" },
{ ID: "UTC-7", Name: "UTC-07:00" },
{ ID: "UTC-6", Name: "UTC-06:00" },
{ ID: "UTC-5", Name: "UTC-05:00" },
{ ID: "UTC-4", Name: "UTC-04:00" },
{ ID: "UTC-3", Name: "UTC-03:00" },
{ ID: "UTC-2", Name: "UTC-02:00" },
{ ID: "UTC-1", Name: "UTC-01:00" },
{ ID: "UTC", Name: "UTC" },
{ ID: "UTC+1", Name: "UTC+01:00" },
{ ID: "UTC+2", Name: "UTC+02:00" },
{ ID: "UTC+3", Name: "UTC+03:00" },
{ ID: "UTC+4", Name: "UTC+04:00" },
{ ID: "UTC+5", Name: "UTC+05:00" },
{ ID: "UTC+6", Name: "UTC+06:00" },
{ ID: "UTC+7", Name: "UTC+07:00" },
{ ID: "UTC+8", Name: "UTC+08:00" },
{ ID: "UTC+9", Name: "UTC+09:00" },
{ ID: "UTC+10", Name: "UTC+10:00" },
{ ID: "UTC+11", Name: "UTC+11:00" },
{ ID: "UTC+12", Name: "UTC+12:00" },
];
export const TimeZones = () =>
[
{ ID: "UTC", Name: "UTC" },
{ ID: "", Name: $gettext("Local Time") },
].concat(timeZonesNames);
[{ ID: "", Name: $gettext("Local Time") }].concat(UtcOffsets).concat(timeZonesNames);
export const Days = () => {
let result = [];

View File

@ -7,8 +7,10 @@ let assert = chai.assert;
describe("options/options", () => {
it("should get timezones", () => {
const timezones = options.TimeZones();
assert.equal(timezones[0].Name, "UTC");
assert.equal(timezones[1].Name, "Local Time");
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");
});
it("should get days", () => {

2
go.mod
View File

@ -3,7 +3,7 @@ module github.com/photoprism/photoprism
require (
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/disintegration/imaging v1.6.2
github.com/djherbis/times v1.5.0
github.com/djherbis/times v1.6.0
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/dsoprea/go-heic-exif-extractor/v2 v2.0.0-20210512044107-62067e44c235
github.com/dsoprea/go-iptc v0.0.0-20200610044640-bc9ca208b413 // indirect

4
go.sum
View File

@ -674,8 +674,8 @@ github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6RO
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/djherbis/times v1.5.0 h1:79myA211VwPhFTqUk8xehWrsEO+zcIZj0zT8mXPVARU=
github.com/djherbis/times v1.5.0/go.mod h1:5q7FDLvbNg1L/KaBmPcWlVR9NmoKo3+ucqUA3ijQhA0=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=

View File

@ -4,15 +4,20 @@ import (
"os"
"path/filepath"
"sync"
"time"
"gopkg.in/yaml.v2"
"github.com/photoprism/photoprism/pkg/fs"
"gopkg.in/yaml.v2"
)
var albumYamlMutex = sync.Mutex{}
// Yaml returns album data as YAML string.
func (m *Album) Yaml() (out []byte, err error) {
m.CreatedAt = m.CreatedAt.UTC().Truncate(time.Second)
m.UpdatedAt = m.UpdatedAt.UTC().Truncate(time.Second)
if err := Db().Model(m).Association("Photos").Find(&m.Photos).Error; err != nil {
log.Errorf("album: %s (yaml)", err)
return out, err

View File

@ -13,7 +13,7 @@ func TestUTC(t *testing.T) {
utc := UTC()
if zone, offset := utc.Zone(); zone != time.UTC.String() {
t.Error("should be utc")
t.Error("should be UTC")
} else if offset != 0 {
t.Error("offset should be 0")
}
@ -27,7 +27,7 @@ func TestUTC(t *testing.T) {
assert.True(t, utcGorm.After(utc))
if zone, offset := utcGorm.Zone(); zone != time.UTC.String() {
t.Error("gorm time should be utc")
t.Error("gorm time should be UTC")
} else if offset != 0 {
t.Error("gorm time offset should be 0")
}
@ -39,7 +39,7 @@ func TestUTC(t *testing.T) {
func TestTimeStamp(t *testing.T) {
t.Run("UTC", func(t *testing.T) {
if TimeStamp().Location() != time.UTC {
t.Fatal("timestamp zone must be utc")
t.Fatal("timestamp zone must be UTC")
}
})
t.Run("Past", func(t *testing.T) {
@ -102,7 +102,7 @@ func TestTimePointer(t *testing.T) {
}
if result.Location() != time.UTC {
t.Fatal("timestamp zone must be utc")
t.Fatal("timestamp zone must be UTC")
}
if result.After(time.Now().Add(time.Second)) {

View File

@ -395,6 +395,7 @@ func (m *Photo) BeforeCreate(scope *gorm.Scope) error {
}
m.PhotoUID = rnd.GenerateUID(PhotoUID)
return scope.SetColumn("PhotoUID", m.PhotoUID)
}

View File

@ -4,6 +4,8 @@ import (
"testing"
"time"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/stretchr/testify/assert"
)
@ -113,7 +115,7 @@ func TestPhoto_SetTakenAt(t *testing.T) {
zone := "Europe/Berlin"
loc, _ := time.LoadLocation(zone)
loc := txt.TimeZone(zone)
newTime := time.Date(2013, 11, 11, 9, 7, 18, 0, loc)

View File

@ -330,9 +330,9 @@ func (m *Photo) CountryCode() string {
// GetTakenAt returns UTC time for TakenAtLocal.
func (m *Photo) GetTakenAt() time.Time {
location, err := time.LoadLocation(m.TimeZone)
location := txt.TimeZone(m.TimeZone)
if err != nil {
if location == nil {
return m.TakenAt
}
@ -345,9 +345,9 @@ func (m *Photo) GetTakenAt() time.Time {
// GetTakenAtLocal returns local time for TakenAt.
func (m *Photo) GetTakenAtLocal() time.Time {
location, err := time.LoadLocation(m.TimeZone)
location := txt.TimeZone(m.TimeZone)
if err != nil {
if location == nil {
return m.TakenAtLocal
}

View File

@ -376,7 +376,7 @@ func TestPhoto_GetTakenAt(t *testing.T) {
utcTime := m.GetTakenAt().Format("2006-01-02T15:04:05")
if utcTime != "2020-02-04T10:54:34" {
t.Fatalf("utc time should be 2020-02-04T10:54:34: %s", utcTime)
t.Fatalf("UTC time should be 2020-02-04T10:54:34: %s", utcTime)
}
}

View File

@ -4,9 +4,11 @@ import (
"os"
"path/filepath"
"sync"
"time"
"gopkg.in/yaml.v2"
"github.com/photoprism/photoprism/pkg/fs"
"gopkg.in/yaml.v2"
)
var photoYamlMutex = sync.Mutex{}
@ -16,6 +18,9 @@ func (m *Photo) Yaml() ([]byte, error) {
// Load details if not done yet.
m.GetDetails()
m.CreatedAt = m.CreatedAt.UTC().Truncate(time.Second)
m.UpdatedAt = m.UpdatedAt.UTC().Truncate(time.Second)
out, err := yaml.Marshal(m)
if err != nil {

View File

@ -19,12 +19,13 @@ type Data struct {
MimeType string `meta:"MIMEType" report:"-"`
DocumentID string `meta:"BurstUUID,MediaGroupUUID,ImageUniqueID,OriginalDocumentID,DocumentID,DigitalImageGUID"`
InstanceID string `meta:"InstanceID,DocumentID"`
CreatedAt time.Time `meta:"SubSecCreateDate,CreationDate,CreateDate,MediaCreateDate,ContentCreateDate,TrackCreateDate"`
CreatedAt time.Time `meta:"SubSecCreateDate,CreationDate,CreateDate,MediaCreateDate,ContentCreateDate,TrackCreateDate,SubSecModifyDate"`
TakenAt time.Time `meta:"SubSecDateTimeOriginal,SubSecDateTimeCreated,DateTimeOriginal,CreationDate,DateTimeCreated,DateTime,DateTimeDigitized" xmp:"DateCreated"`
TakenAtLocal time.Time `meta:"SubSecDateTimeOriginal,SubSecDateTimeCreated,DateTimeOriginal,CreationDate,DateTimeCreated,DateTime,DateTimeDigitized"`
TakenGps time.Time `meta:"GPSDateTime,GPSDateStamp"`
TakenNs int `meta:"-"`
TimeZone string `meta:"-"`
TimeOffset string `meta:"OffsetTime,OffsetTimeOriginal,OffsetTimeDigitized"`
MediaType media.Type `meta:"-"`
HasThumbEmbedded bool `meta:"ThumbnailImage,PhotoshopThumbnail" report:"-"`
HasVideoEmbedded bool `meta:"EmbeddedVideoFile,MotionPhoto,MotionPhotoVideo,MicroVideo" report:"-"`

View File

@ -235,7 +235,7 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
} else if mt, ok := data.json["MIMEType"]; ok && data.TakenAtLocal.IsZero() && (mt == MimeVideoMP4 || mt == MimeQuicktime) {
// Assume default time zone for MP4 & Quicktime videos is UTC.
// see https://exiftool.org/TagNames/QuickTime.html
log.Debugf("metadata: %s uses utc by default (%s)", logName, clean.Log(mt))
log.Debugf("metadata: default time zone for %s is UTC (%s)", logName, clean.Log(mt))
data.TimeZone = time.UTC.String()
data.TakenAt = data.TakenAt.UTC()
data.TakenAtLocal = time.Time{}
@ -252,8 +252,8 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
data.TimeZone = zones[0]
}
if loc, err := time.LoadLocation(data.TimeZone); err != nil {
log.Warnf("metadata: unknown time zone %s (exiftool)", data.TimeZone)
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 localUtc, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), time.UTC); err == nil {
@ -274,15 +274,25 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
}
} 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 {
data.TakenAtLocal = localUtc
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() {
// 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 data.TimeOffset != "" {
log.Infof("metadata: %s has invalid time offset %s (exiftool)", logName, clean.Log(data.TimeOffset))
}
// Set local time if still empty.
if data.TakenAtLocal.IsZero() && !data.TakenAt.IsZero() {
if loc, err := time.LoadLocation(data.TimeZone); data.TimeZone == "" || err != nil {
if loc := txt.TimeZone(data.TimeZone); data.TimeZone == "" || 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 {
data.TakenAtLocal = localUtc

View File

@ -6,6 +6,7 @@ import (
"runtime/debug"
"time"
"github.com/photoprism/photoprism/pkg/txt"
"gopkg.in/photoprism/go-tz.v2/tz"
)
@ -147,8 +148,8 @@ func (data *Data) GPhoto(jsonData []byte) (err error) {
}
if !data.TakenAtLocal.IsZero() {
if loc, err := time.LoadLocation(data.TimeZone); err != nil {
log.Warnf("metadata: unknown time zone %s (gphotos)", data.TimeZone)
if loc := txt.TimeZone(data.TimeZone); loc == nil {
log.Warnf("metadata: invalid time zone %s (gphotos)", data.TimeZone)
} else if tl, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); err == nil {
data.TakenAt = tl.UTC().Truncate(time.Second)
} else {

View File

@ -152,7 +152,8 @@ func TestJSON_Motion(t *testing.T) {
assert.Equal(t, "2023-08-22 14:38:03 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2023-08-22 14:38:03 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 583000000, data.TakenNs)
assert.Equal(t, "", data.TimeZone)
assert.Equal(t, "UTC+3", data.TimeZone)
assert.Equal(t, "+03:00", data.TimeOffset)
assert.Equal(t, 4032, data.Width)
assert.Equal(t, 3024, data.Height)
assert.Equal(t, 3024, data.ActualWidth())
@ -245,7 +246,8 @@ func TestJSON_Motion(t *testing.T) {
assert.Equal(t, "2021-10-11 11:34:27 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2021-10-11 11:34:27 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
assert.Equal(t, "", data.TimeZone)
assert.Equal(t, "UTC+2", data.TimeZone)
assert.Equal(t, "+02:00", data.TimeOffset)
assert.Equal(t, 4000, data.Width)
assert.Equal(t, 2252, data.Height)
assert.Equal(t, 4000, data.ActualWidth())
@ -277,6 +279,7 @@ func TestJSON_Motion(t *testing.T) {
assert.Equal(t, "2021-10-11 09:34:29 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
assert.Equal(t, "UTC", data.TimeZone)
assert.Equal(t, "", data.TimeOffset)
assert.Equal(t, 1920, data.Width)
assert.Equal(t, 1080, data.Height)
assert.Equal(t, 1920, data.ActualWidth())
@ -305,7 +308,10 @@ func TestJSON_Motion(t *testing.T) {
assert.Equal(t, int64(0), data.Duration.Milliseconds())
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, 308000000, data.TakenNs)
assert.Equal(t, "", data.TimeZone)
assert.Equal(t, "2023-04-24 13:03:58 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2023-04-24 13:03:58 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "UTC+2", data.TimeZone)
assert.Equal(t, "+02:00", data.TimeOffset)
assert.Equal(t, 4624, data.Width)
assert.Equal(t, 3468, data.Height)
assert.Equal(t, 3468, data.ActualWidth())

View File

@ -29,6 +29,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2023-06-02 10:01:51 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, "", data.TimeOffset)
assert.Equal(t, 1920, data.Width)
assert.Equal(t, 1440, data.Height)
assert.Equal(t, 1920, data.ActualWidth())
@ -58,6 +59,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2018-09-08 17:20:14 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, "", data.TimeOffset)
assert.Equal(t, 1920, data.Width)
assert.Equal(t, 1080, data.Height)
assert.Equal(t, 1080, data.ActualWidth())
@ -165,6 +167,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2020-05-11 14:18:35 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 0, data.TakenNs)
assert.Equal(t, time.UTC.String(), data.TimeZone)
assert.Equal(t, "", data.TimeOffset)
assert.Equal(t, 270, data.Width)
assert.Equal(t, 480, data.Height)
assert.Equal(t, 270, data.ActualWidth())
@ -707,6 +710,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2015-12-06 18:22:29 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2015-12-06 15:22:29 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Moscow", data.TimeZone)
assert.Equal(t, "", data.TimeOffset)
assert.Equal(t, 1920, data.Width)
assert.Equal(t, 1080, data.Height)
assert.Equal(t, 1920, data.ActualWidth())
@ -730,7 +734,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "6.83s", data.Duration.String())
assert.Equal(t, "2020-12-22 02:45:43 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2020-12-22 01:45:43 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "", data.TimeZone)
assert.Equal(t, "UTC+1", data.TimeZone)
assert.Equal(t, 1920, data.Width)
assert.Equal(t, 1080, data.Height)
assert.Equal(t, 1080, data.ActualWidth())
@ -791,7 +795,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "6.09s", data.Duration.String())
assert.Equal(t, "2022-06-25 06:50:58 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2022-06-25 04:50:58 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "", data.TimeZone) // Local Time
assert.Equal(t, "UTC+2", data.TimeZone) // Local Time
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, float32(0), data.Lat)
assert.Equal(t, float32(0), data.Lng)
@ -914,7 +918,7 @@ func TestJSON(t *testing.T) {
// t.Logf("all: %+v", data.json)
assert.Equal(t, "Jens\r\tMander", data.Artist)
assert.Equal(t, "", data.TimeZone)
assert.Equal(t, "UTC+2", data.TimeZone)
assert.Equal(t, "2004-10-07 20:49:16 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "2004-10-07 22:49:16 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "This is the title", data.Title)
@ -1256,7 +1260,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2022-11-02 12:54:16 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2022-11-02 11:54:16 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 698000000, data.TakenNs)
assert.Equal(t, "", data.TimeZone)
assert.Equal(t, "UTC+1", data.TimeZone)
assert.Equal(t, 4032, data.Width)
assert.Equal(t, 3024, data.Height)
assert.Equal(t, 6, data.Orientation)
@ -1281,7 +1285,7 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "2022-09-23 13:30:04 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2022-09-23 12:30:04 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 630000000, data.TakenNs)
assert.Equal(t, "", data.TimeZone)
assert.Equal(t, "UTC+1", data.TimeZone)
assert.Equal(t, 4032, data.Width)
assert.Equal(t, 3024, data.Height)
assert.Equal(t, 1, data.Orientation)
@ -1324,4 +1328,26 @@ func TestJSON(t *testing.T) {
assert.InEpsilon(t, 4294967284, data.Altitude, 1000)
assert.Equal(t, 0, clean.Altitude(data.Altitude))
})
t.Run("timeoffset.json", func(t *testing.T) {
data, err := JSON("testdata/timeoffset.json", "")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %#v", data)
assert.Equal(t, "IMG_9395.heic", data.FileName)
assert.Equal(t, "2023-10-02 13:20:17 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2023-10-02 11:20:17 +0000 UTC", data.TakenAt.String())
assert.Equal(t, 568000000, data.TakenNs)
assert.Equal(t, "UTC+2", data.TimeZone)
assert.Equal(t, "+02:00", data.TimeOffset)
assert.Equal(t, float32(0), data.Lat)
assert.Equal(t, float32(0), data.Lng)
assert.Equal(t, "Apple", data.CameraMake)
assert.Equal(t, "iPhone 13", data.CameraModel)
assert.Equal(t, "iPhone 13 back dual wide camera 5.1mm f/1.6", data.LensModel)
})
}

View File

@ -0,0 +1,16 @@
package meta
import (
"os"
"testing"
"github.com/sirupsen/logrus"
)
func TestMain(m *testing.M) {
log = logrus.StandardLogger()
log.SetLevel(logrus.TraceLevel)
code := m.Run()
os.Exit(code)
}

143
internal/meta/testdata/timeoffset.json vendored Normal file
View File

@ -0,0 +1,143 @@
[{
"SourceFile": "IMG_9395.heic",
"ExifToolVersion": 12.56,
"FileName": "IMG_9395.heic",
"Directory": ".",
"FileSize": 2998209,
"FileModifyDate": "2023:10:02 14:28:10+00:00",
"FileAccessDate": "2023:10:20 14:58:03+00:00",
"FileInodeChangeDate": "2023:10:20 14:57:28+00:00",
"FilePermissions": 100664,
"FileType": "HEIC",
"FileTypeExtension": "HEIC",
"MIMEType": "image/heic",
"MajorBrand": "heic",
"MinorVersion": "0.0.0",
"CompatibleBrands": ["mif1","MiPr","miaf","MiHB","heic"],
"HandlerType": "pict",
"PrimaryItemReference": 49,
"MetaImageSize": "0 1287 4032 3024",
"ExifByteOrder": "MM",
"Make": "Apple",
"Model": "iPhone 13",
"Orientation": 6,
"XResolution": 72,
"YResolution": 72,
"ResolutionUnit": 2,
"Software": "16.6.1",
"ModifyDate": "2023:10:02 13:20:17",
"HostComputer": "iPhone 13",
"ExposureTime": 0.006134969325,
"FNumber": 1.6,
"ExposureProgram": 2,
"ISO": 40,
"ExifVersion": "0232",
"DateTimeOriginal": "2023:10:02 13:20:17",
"CreateDate": "2023:10:02 13:20:17",
"OffsetTime": "+02:00",
"OffsetTimeOriginal": "+02:00",
"OffsetTimeDigitized": "+02:00",
"ShutterSpeedValue": "0.00613700001355557",
"ApertureValue": 1.59999999932056,
"BrightnessValue": 5.937361762,
"ExposureCompensation": 0,
"MeteringMode": 5,
"Flash": 16,
"FocalLength": 5.1,
"SubjectArea": "2011 1508 2323 1330",
"MakerNoteVersion": 14,
"RunTimeFlags": 1,
"RunTimeValue": 515774842809958,
"RunTimeScale": 1000000000,
"RunTimeEpoch": 0,
"AEStable": 1,
"AETarget": 213,
"AEAverage": 213,
"AFStable": 1,
"AccelerationVector": "0.01318094506 0.00392230414 -1.016510248",
"FocusDistanceRange": "1.39453125 0.484375",
"LivePhotoVideoIndex": 1107304448,
"HDRHeadroom": 0,
"SignalToNoiseRatio": 48.9287796,
"FocusPosition": 189,
"SemanticStyle": "{0=1,1=0,2=0.5,3=4}",
"FrontFacingCamera": 0,
"SubSecTimeOriginal": 568,
"SubSecTimeDigitized": 568,
"ColorSpace": 65535,
"ExifImageWidth": 4032,
"ExifImageHeight": 3024,
"SensingMethod": 2,
"SceneType": 1,
"ExposureMode": 0,
"WhiteBalance": 0,
"FocalLengthIn35mmFormat": 26,
"LensInfo": "1.539999962 5.1 1.6 2.4",
"LensMake": "Apple",
"LensModel": "iPhone 13 back dual wide camera 5.1mm f/1.6",
"CompositeImage": 2,
"ProfileCMMType": "appl",
"ProfileVersion": 1024,
"ProfileClass": "mntr",
"ColorSpaceData": "RGB ",
"ProfileConnectionSpace": "XYZ ",
"ProfileDateTime": "2022:01:01 00:00:00",
"ProfileFileSignature": "acsp",
"PrimaryPlatform": "APPL",
"CMMFlags": 0,
"DeviceManufacturer": "APPL",
"DeviceModel": "",
"DeviceAttributes": "0 0",
"RenderingIntent": 0,
"ConnectionSpaceIlluminant": "0.9642 1 0.82491",
"ProfileCreator": "appl",
"ProfileID": "236 253 163 142 56 133 71 195 109 180 189 79 122 218 24 47",
"ProfileDescription": "Display P3",
"ProfileCopyright": "Copyright Apple Inc., 2022",
"MediaWhitePoint": "0.96419 1 0.82489",
"RedMatrixColumn": "0.51512 0.2412 -0.00105",
"GreenMatrixColumn": "0.29198 0.69225 0.04189",
"BlueMatrixColumn": "0.1571 0.06657 0.78407",
"RedTRC": "(Binary data 32 bytes, use -b option to extract)",
"ChromaticAdaptation": "1.04788 0.02292 -0.0502 0.02959 0.99048 -0.01706 -0.00923 0.01508 0.75168",
"BlueTRC": "(Binary data 32 bytes, use -b option to extract)",
"GreenTRC": "(Binary data 32 bytes, use -b option to extract)",
"HEVCConfigurationVersion": 1,
"GeneralProfileSpace": 0,
"GeneralTierFlag": 0,
"GeneralProfileIDC": 3,
"GenProfileCompatibilityFlags": 1879048192,
"ConstraintIndicatorFlags": "176 0 0 0 0 0",
"GeneralLevelIDC": 90,
"MinSpatialSegmentationIDC": 0,
"ParallelismType": 0,
"ChromaFormat": 1,
"BitDepthLuma": 8,
"BitDepthChroma": 8,
"AverageFrameRate": 0,
"ConstantFrameRate": 0,
"NumTemporalLayers": 1,
"TemporalIDNested": 0,
"ImageWidth": 4032,
"ImageHeight": 3024,
"ImageSpatialExtent": "4032 3024",
"Rotation": 270,
"ImagePixelDepth": "8 8 8",
"MediaDataSize": 2994790,
"MediaDataOffset": 3419,
"RunTimeSincePowerUp": 515774.842809958,
"Aperture": 1.6,
"ImageSize": "4032 3024",
"Megapixels": 12.192768,
"ScaleFactor35efl": 5.09803921568628,
"ShutterSpeed": 0.006134969325,
"SubSecCreateDate": "2023:10:02 13:20:17.568+02:00",
"SubSecDateTimeOriginal": "2023:10:02 13:20:17.568+02:00",
"SubSecModifyDate": "2023:10:02 13:20:17+02:00",
"CircleOfConfusion": "0.00589368958489306",
"FOV": 69.3903656740024,
"FocalLength35efl": 26,
"HyperfocalDistance": 2.75824672572995,
"LightValue": 10.0268000593798,
"LensID": "iPhone 13 back dual wide camera 5.1mm f/1.6"
}]

View File

@ -96,10 +96,10 @@ func DateTime(s, timeZone string) (t time.Time) {
}
// Set time zone.
loc, err := time.LoadLocation(timeZone)
loc := TimeZone(timeZone)
// Location found?
if err == nil && timeZone != "" && tz != time.Local {
if loc != nil && timeZone != "" && tz != time.Local {
tz = loc
timeZone = tz.String()
} else {

View File

@ -127,6 +127,11 @@ func TestDateTime(t *testing.T) {
assert.Equal(t, "2022-09-04 00:48:26 +0000 UTC", result.UTC().String())
assert.Equal(t, "2022-09-03 17:48:26", result.Format("2006-01-02 15:04:05"))
})
t.Run("2016:06:28 09:45:49 UTC+2", func(t *testing.T) {
result := DateTime("2016:06:28 09:45:49 +0000 UTC", "UTC+2")
assert.Equal(t, "2016-06-28 09:45:49 +0200 UTC+2", result.String())
assert.Equal(t, "2016-06-28 07:45:49 +0000 UTC", result.UTC().String())
})
}
func TestIsTime(t *testing.T) {

182
pkg/txt/timezone.go Normal file
View File

@ -0,0 +1,182 @@
package txt
import (
"fmt"
"math"
"strings"
"time"
)
// 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
}
return time.FixedZone(fmt.Sprintf("UTC%+d", sec/3600), sec)
} else if location, err := time.LoadLocation(offset); err == nil {
return location
}
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
}
// 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 "+0", "+00", "00:00", "+00:00", "-00:00", "Z", "Z00:00", "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(local, utc 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 d < -12 || d > 12 {
return ""
} else if d == 0 {
return time.UTC.String()
}
return fmt.Sprintf("UTC%+d", int(d))
}
func TimeOffset(s string) (seconds int) {
switch s {
case "-12", "-12:00", "UTC-12", "UTC-12:00":
return -12 * 3600
case "-11", "-11:00", "UTC-11", "UTC-11:00":
return -11 * 3600
case "-10", "-10:00", "UTC-10", "UTC-10:00":
return -10 * 3600
case "-9", "-09", "-09:00", "UTC-9", "UTC-09:00":
return -9 * 3600
case "-8", "-08", "-08:00", "UTC-8", "UTC-08:00":
return -8 * 3600
case "-7", "-07", "-07:00", "UTC-7", "UTC-07:00":
return -7 * 3600
case "-6", "-06", "-06:00", "UTC-6", "UTC-06:00":
return -6 * 3600
case "-5", "-05", "-05:00", "UTC-5", "UTC-05:00":
return -5 * 3600
case "-4", "-04", "-04:00", "UTC-4", "UTC-04:00":
return -4 * 3600
case "-3", "-03", "-03:00", "UTC-3", "UTC-03:00":
return -3 * 3600
case "-2", "-02", "-02:00", "UTC-2", "UTC-02:00":
return -2 * 3600
case "-1", "-01", "-01:00", "UTC-1", "UTC-01:00":
return -1 * 3600
case "01:00", "+1", "+01", "+01:00", "UTC+1", "UTC+01:00":
return 1 * 3600
case "02:00", "+2", "+02", "+02:00", "UTC+2", "UTC+02:00":
return 2 * 3600
case "03:00", "+3", "+03", "+03:00", "UTC+3", "UTC+03:00":
return 3 * 3600
case "04:00", "+4", "+04", "+04:00", "UTC+4", "UTC+04:00":
return 4 * 3600
case "05:00", "+5", "+05", "+05:00", "UTC+5", "UTC+05:00":
return 5 * 3600
case "06:00", "+6", "+06", "+06:00", "UTC+6", "UTC+06:00":
return 6 * 3600
case "07:00", "+7", "+07", "+07:00", "UTC+7", "UTC+07:00":
return 7 * 3600
case "08:00", "+8", "+08", "+08:00", "UTC+8", "UTC+08:00":
return 8 * 3600
case "09:00", "+9", "+09", "+09:00", "UTC+9", "UTC+09:00":
return 9 * 3600
case "10:00", "+10", "+10:00", "UTC+10", "UTC+10:00":
return 10 * 3600
case "11:00", "+11", "+11:00", "UTC+11", "UTC+11:00":
return 11 * 3600
case "12:00", "+12", "+12:00", "UTC+12", "UTC+12:00":
return 12 * 3600
default:
return 0
}
}

234
pkg/txt/timezone_test.go Normal file
View File

@ -0,0 +1,234 @@
package txt
import (
"testing"
"time"
"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())
})
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")
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(local, utc, "")
assert.Equal(t, "UTC+2", timeZone)
loc := TimeZone(timeZone)
assert.Equal(t, "UTC+2", loc.String())
})
}
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"))
assert.Equal(t, "UTC-2", NormalizeUtcOffset("UTC-02:00"))
assert.Equal(t, "UTC-2", NormalizeUtcOffset("-02:00"))
assert.Equal(t, "UTC-2", NormalizeUtcOffset("-02"))
assert.Equal(t, "UTC-2", NormalizeUtcOffset("-2"))
assert.Equal(t, "UTC", NormalizeUtcOffset("UTC"))
assert.Equal(t, "UTC", NormalizeUtcOffset("UTC+0"))
assert.Equal(t, "UTC", NormalizeUtcOffset("UTC-00:00"))
assert.Equal(t, "UTC", NormalizeUtcOffset("UTC+00:00"))
assert.Equal(t, "UTC", NormalizeUtcOffset("Z"))
assert.Equal(t, "UTC+1", NormalizeUtcOffset("UTC+1"))
assert.Equal(t, "UTC+2", NormalizeUtcOffset("UTC+2"))
assert.Equal(t, "UTC+12", NormalizeUtcOffset("UTC+12"))
assert.Equal(t, "UTC+12", NormalizeUtcOffset("+12"))
assert.Equal(t, "UTC+12", NormalizeUtcOffset("+12:00"))
assert.Equal(t, "UTC+12", NormalizeUtcOffset("12:00"))
assert.Equal(t, "UTC+12", NormalizeUtcOffset("UTC+12:00"))
})
t.Run("Invalid", func(t *testing.T) {
assert.Equal(t, "", NormalizeUtcOffset("UTC-15"))
assert.Equal(t, "", NormalizeUtcOffset("UTC-14:00"))
assert.Equal(t, "", NormalizeUtcOffset("UTC-14"))
assert.Equal(t, "", NormalizeUtcOffset("UTC--2"))
assert.Equal(t, "", NormalizeUtcOffset("UTC1"))
assert.Equal(t, "", NormalizeUtcOffset("UTC13"))
assert.Equal(t, "", NormalizeUtcOffset("UTC+13"))
})
}
func TestUtcOffset(t *testing.T) {
t.Run("GMT", 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 13:20:17 +00:00")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "", UtcOffset(local, utc, ""))
})
t.Run("UTC", 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 13:20:17 +00:00")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "UTC", UtcOffset(local, utc, "00:00"))
assert.Equal(t, "UTC", UtcOffset(local, utc, "+00:00"))
assert.Equal(t, "UTC", UtcOffset(local, utc, "Z"))
})
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")
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(local, utc, "")
assert.Equal(t, "UTC+2", timeZone)
loc := time.FixedZone("UTC+2", 2*3600)
assert.Equal(t, "UTC+2", loc.String())
})
t.Run("+02:00", 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 13:20:17 +00:00")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "UTC+2", UtcOffset(local, utc, "02:00"))
})
t.Run("UTC+2.5", func(t *testing.T) {
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 13:50: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)
}
assert.Equal(t, "", UtcOffset(local, utc, ""))
})
t.Run("+02:30", func(t *testing.T) {
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 13:50: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)
}
assert.Equal(t, "", UtcOffset(local, utc, "+02:30"))
})
t.Run("UTC-14", func(t *testing.T) {
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 00: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 14:20:17 +00:00")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "", UtcOffset(local, utc, ""))
})
t.Run("UTC-15", func(t *testing.T) {
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 00: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 15:20:17 +00:00")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "", UtcOffset(local, utc, ""))
})
}
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"))
})
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"))
})
}