From 0f47c84138cddcb45627b26208fc964d8014f285 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Thu, 19 Sep 2019 14:23:39 -0700 Subject: [PATCH] Implement time zone support for "TakenAt" Signed-off-by: Michael Mayer --- frontend/package-lock.json | 15 ++-- frontend/package.json | 4 +- frontend/src/app.js | 7 +- frontend/src/common/api.js | 2 +- frontend/src/component/p-photo-list.vue | 2 +- frontend/src/model/album.js | 4 +- frontend/src/model/label.js | 4 +- frontend/src/model/photo.js | 10 ++- frontend/src/pages/albums2.vue | 4 +- frontend/tests/unit/common/clipboard_test.js | 2 +- frontend/tests/unit/model/album_test.js | 9 ++- frontend/tests/unit/model/label_test.js | 7 +- frontend/tests/unit/model/photo_test.js | 7 +- go.mod | 1 + go.sum | 4 ++ internal/models/photo.go | 1 + internal/photoprism/exif.go | 76 +++++++++++++------- internal/photoprism/exif_test.go | 3 + internal/photoprism/indexer.go | 18 ++--- internal/photoprism/mediafile.go | 16 ----- internal/photoprism/mediafile_test.go | 23 +----- internal/photoprism/search_result.go | 1 + internal/photoprism/timezone.go | 30 ++++++++ internal/photoprism/timezone_test.go | 23 ++++++ 24 files changed, 169 insertions(+), 104 deletions(-) create mode 100644 internal/photoprism/timezone.go create mode 100644 internal/photoprism/timezone_test.go diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6b7ae0969..1cf5f3acf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6849,6 +6849,11 @@ "yallist": "^2.1.2" } }, + "luxon": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.17.3.tgz", + "integrity": "sha512-gG894ALYdWB3izyTGDypF6ffablXs5oleJLRNboL/xxYa0Dm5apXihaCnHecMXNqQ3vW1qjCCZ+fXAX5Azgx3Q==" + }, "make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -11157,12 +11162,12 @@ } } }, - "vue-moment": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/vue-moment/-/vue-moment-4.0.0.tgz", - "integrity": "sha512-lNkEPuA3i3A4q4TDSwOXoRF4Y2vHHdaTOSvpPyGgxoFQP8n4sUh6jU5aJj3FIMlXo5UaHLPIz5hvvpvYx9Wj0w==", + "vue-luxon": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/vue-luxon/-/vue-luxon-0.7.0.tgz", + "integrity": "sha512-EycNzfRqTi97kj+1UyjadezNCXgQHSgE21yfAxCKQqylAl9AOZi2QSe6OeteH3BivZv/w7CdJfFNEqttGpVQ4Q==", "requires": { - "moment": "^2.11.1" + "luxon": "^1.17.2" } }, "vue-router": { diff --git a/frontend/package.json b/frontend/package.json index 02aad4cb7..a48c0fff0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -65,10 +65,10 @@ "karma-verbose-reporter": "^0.0.6", "karma-webpack": "^4.0.2", "leaflet": "^1.5.1", + "luxon": "^1.17.3", "material-design-icons-iconfont": "^5.0.1", "mini-css-extract-plugin": "^0.7.0", "mocha": "^6.2.0", - "moment": "^2.24.0", "moment-timezone": "^0.5.26", "optimize-css-assets-webpack-plugin": "^5.0.3", "ora": "^3.4.0", @@ -96,7 +96,7 @@ "vue-fullscreen": "^2.1.5", "vue-infinite-scroll": "^2.0.2", "vue-loader": "^14.2.4", - "vue-moment": "^4.0.0", + "vue-luxon": "^0.7.0", "vue-router": "^3.1.3", "vue-style-loader": "^4.1.2", "vue-template-compiler": "^2.6.10", diff --git a/frontend/src/app.js b/frontend/src/app.js index 6916b0968..54bb5a4c2 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -13,10 +13,11 @@ import Alert from "common/alert"; import Viewer from "common/viewer"; import Session from "common/session"; import Event from "pubsub-js"; -import VueMoment from "vue-moment"; +import VueLuxon from "vue-luxon"; import VueInfiniteScroll from "vue-infinite-scroll"; import VueFullscreen from "vue-fullscreen"; import VueFilters from "vue2-filters"; +import { Settings } from "luxon"; // Initialize helpers const session = new Session(window.localStorage); @@ -48,8 +49,10 @@ Vue.use(Vuetify, { }, }); +Settings.defaultLocale = "en"; + // Register other VueJS plugins -Vue.use(VueMoment); +Vue.use(VueLuxon); Vue.use(VueInfiniteScroll); Vue.use(VueFullscreen); Vue.use(VueFilters); diff --git a/frontend/src/common/api.js b/frontend/src/common/api.js index 10945358e..3c976b8ac 100644 --- a/frontend/src/common/api.js +++ b/frontend/src/common/api.js @@ -1,6 +1,6 @@ import axios from "axios"; import Event from "pubsub-js"; -import "@babel/polyfill"; +import "@babel/polyfill/noConflict"; const Api = axios.create({ baseURL: "/api/v1", diff --git a/frontend/src/component/p-photo-list.vue b/frontend/src/component/p-photo-list.vue index 47eae0757..2cee6ed42 100644 --- a/frontend/src/component/p-photo-list.vue +++ b/frontend/src/component/p-photo-list.vue @@ -19,7 +19,7 @@ {{ props.item.PhotoTitle }} - {{ props.item.TakenAt | moment('DD/MM/YYYY hh:mm:ss') }} + {{ props.item.TakenAt | luxon:format('dd/MM/yyyy hh:mm:ss') }} {{ props.item.LocCountry }} {{ props.item.CameraMake }} {{ props.item.CameraModel }} {{ props.item.PhotoTitle }} - {{ props.item.TakenAt | moment('DD/MM/YYYY hh:mm:ss') }} + {{ props.item.TakenAt | luxon:format('dd/MM/yyyy hh:mm:ss') }} {{ props.item.LocCity }} {{ props.item.LocCountry }} {{ props.item.CameraModel }} @@ -240,7 +240,7 @@ truncate(80) }}
date_range - {{ photo.TakenAt | moment('DD/MM/YYYY hh:mm:ss') }} + {{ photo.TakenAt | luxon:format('dd/MM/yyyy hh:mm:ss') }}
photo_camera {{ photo.getCamera() }} diff --git a/frontend/tests/unit/common/clipboard_test.js b/frontend/tests/unit/common/clipboard_test.js index 627f17578..557a6432c 100644 --- a/frontend/tests/unit/common/clipboard_test.js +++ b/frontend/tests/unit/common/clipboard_test.js @@ -175,4 +175,4 @@ describe("common/clipboard", () => { assert.equal(clipboard.selection, ""); }); -}); \ No newline at end of file +}); diff --git a/frontend/tests/unit/model/album_test.js b/frontend/tests/unit/model/album_test.js index 0ed06db33..9cc46eea5 100644 --- a/frontend/tests/unit/model/album_test.js +++ b/frontend/tests/unit/model/album_test.js @@ -48,18 +48,17 @@ describe("model/album", () => { }); it("should get thumbnail sizes", () => { - const values = {id: 5, AlbumName: "Christmas 2019", AlbumSlug: "christmas-2019"}; + const values = {id: 5, AlbumName: "Christmas 2019", AlbumSlug: "christmas-2019", CreatedAt: "2012-07-08T14:45:39Z"}; const album = new Album(values); const result = album.getThumbnailSizes(); assert.equal(result, "(min-width: 2560px) 3840px, (min-width: 1920px) 2560px, (min-width: 1280px) 1920px, (min-width: 720px) 1280px, 720px"); }); it("should get date string", () => { - const t = "2012-07-08 14:45:39"; - const values = {ID: 5, AlbumName: "Christmas 2019", AlbumSlug: "christmas-2019", CreatedAt: t}; + const values = {ID: 5, AlbumName: "Christmas 2019", AlbumSlug: "christmas-2019", CreatedAt: "2012-07-08T14:45:39Z"}; const album = new Album(values); const result = album.getDateString(); - assert.equal(result, "July 8, 2012 2:45 PM"); + assert.equal(result, "Jul 8, 2012, 2:45 PM"); }); it("should get model name", () => { @@ -97,4 +96,4 @@ describe("model/album", () => { album.toggleLike(); assert.equal(album.AlbumFavorite, true); }); -}); \ No newline at end of file +}); diff --git a/frontend/tests/unit/model/label_test.js b/frontend/tests/unit/model/label_test.js index 4754d286d..33937afa4 100644 --- a/frontend/tests/unit/model/label_test.js +++ b/frontend/tests/unit/model/label_test.js @@ -55,11 +55,10 @@ describe("model/label", () => { }); it("should get date string", () => { - const t = "2012-07-08 14:45:39"; - const values = {ID: 5, LabelName: "Black Cat", LabelSlug: "black-cat", CreatedAt: t}; + const values = {ID: 5, LabelName: "Black Cat", LabelSlug: "black-cat", CreatedAt: "2012-07-08T14:45:39Z"}; const label = new Label(values); const result = label.getDateString(); - assert.equal(result, "July 8, 2012 2:45 PM"); + assert.equal(result, "Jul 8, 2012, 2:45 PM"); }); it("should get model name", () => { @@ -97,4 +96,4 @@ describe("model/label", () => { label.toggleLike(); assert.equal(label.LabelFavorite, true); }); -}); \ No newline at end of file +}); diff --git a/frontend/tests/unit/model/photo_test.js b/frontend/tests/unit/model/photo_test.js index b454dc10f..a34d3b82a 100644 --- a/frontend/tests/unit/model/photo_test.js +++ b/frontend/tests/unit/model/photo_test.js @@ -114,11 +114,10 @@ describe("model/photo", () => { }); it("should get date string", () => { - const t = "2012-07-08 14:45:39"; - const values = {ID: 5, PhotoTitle: "Crazy Cat", TakenAt: t}; + const values = {ID: 5, PhotoTitle: "Crazy Cat", TakenAt: "2012-07-08T14:45:39Z", TimeZone: "UTC"}; const photo = new Photo(values); const result = photo.getDateString(); - assert.equal(result, "July 8, 2012 2:45 PM"); + assert.equal(result, "July 8, 2012, 2:45 PM UTC"); }); it("should test whether photo has location", () => { @@ -234,4 +233,4 @@ describe("model/photo", () => { assert.equal(photo.PhotoFavorite, true); }); -}); \ No newline at end of file +}); diff --git a/go.mod b/go.mod index a4e81898e..69dd8e0af 100644 --- a/go.mod +++ b/go.mod @@ -65,6 +65,7 @@ require ( gopkg.in/go-playground/assert.v1 v1.2.1 // indirect gopkg.in/go-playground/validator.v8 v8.18.2 // indirect gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce // indirect + gopkg.in/ugjka/go-tz.v2 v2.0.8 gopkg.in/yaml.v2 v2.2.2 ) diff --git a/go.sum b/go.sum index 022f469f5..29fb8dfc6 100644 --- a/go.sum +++ b/go.sum @@ -170,6 +170,8 @@ github.com/lib/pq v1.1.0 h1:/5u4a+KGJptBRqGzPvYQL9p0d/tPR4S31+Tnzj9lEO4= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lucasb-eyer/go-colorful v1.0.2 h1:mCMFu6PgSozg9tDNMMK3g18oJBX7oYGrC09mS6CXfO4= github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/machinebox/progress v0.2.0/go.mod h1:hl4FywxSjfmkmCrersGhmJH7KwuKl+Ueq9BXkOny+iE= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= @@ -408,6 +410,8 @@ gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXL gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/ugjka/go-tz.v2 v2.0.8 h1:EmQ1tY6aa9upe1EDqyPdyHgM9STimr2fw7+b/CuMQ94= +gopkg.in/ugjka/go-tz.v2 v2.0.8/go.mod h1:l93M5EnjrSTTaABtGKIqNcl+z08IYbVDS7v1VA8+PgU= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/models/photo.go b/internal/models/photo.go index 50488cbcb..139f758fe 100644 --- a/internal/models/photo.go +++ b/internal/models/photo.go @@ -42,6 +42,7 @@ type Photo struct { LocationChanged bool LocationEstimated bool TakenAt time.Time `gorm:"index;"` + TakenAtLocal time.Time TakenAtChanged bool TimeZone string Labels []*PhotoLabel diff --git a/internal/photoprism/exif.go b/internal/photoprism/exif.go index 10da656b3..b34774d09 100644 --- a/internal/photoprism/exif.go +++ b/internal/photoprism/exif.go @@ -9,29 +9,31 @@ import ( "time" "github.com/dsoprea/go-exif" + "gopkg.in/ugjka/go-tz.v2/tz" ) // Exif returns information about a single image. type Exif struct { - UUID string - TakenAt time.Time - TimeZone string - Artist string - CameraMake string - CameraModel string - LensMake string - LensModel string - FocalLength int - Exposure string - Aperture float64 - Iso int - Lat float64 - Long float64 - Altitude int - Width int - Height int - Orientation int - All map[string]string + UUID string + TakenAt time.Time + TakenAtLocal time.Time + TimeZone string + Artist string + CameraMake string + CameraModel string + LensMake string + LensModel string + FocalLength int + Exposure string + Aperture float64 + Iso int + Lat float64 + Long float64 + Altitude int + Width int + Height int + Orientation int + All map[string]string } var im *exif.IfdMapping @@ -178,14 +180,6 @@ func (m *MediaFile) Exif() (result *Exif, err error) { } } - if value, ok := tags["DateTimeOriginal"]; ok { - m.exifData.TakenAt, _ = time.Parse("2006:01:02 15:04:05", value) - } - - if value, ok := tags["TimeZoneOffset"]; ok { - m.exifData.TimeZone = value - } - if value, ok := tags["ImageUniqueID"]; ok { m.exifData.UUID = value } @@ -224,6 +218,34 @@ func (m *MediaFile) Exif() (result *Exif, err error) { } } + if m.exifData.Lat != 0 && m.exifData.Long != 0 { + zones, err := tz.GetZone(tz.Point{ + Lat: m.exifData.Lat, + Lon: m.exifData.Long, + }) + + if err != nil { + m.exifData.TimeZone = "UTC" + } + + m.exifData.TimeZone = zones[0] + } + + if value, ok := tags["DateTimeOriginal"]; ok { + m.exifData.TakenAtLocal, _ = time.Parse("2006:01:02 15:04:05", value) + + loc, err := time.LoadLocation(m.exifData.TimeZone) + + if err != nil { + m.exifData.TakenAt = m.exifData.TakenAtLocal + log.Warnf("no location for timezone: %s", err.Error()) + } else if tl, err := time.ParseInLocation("2006:01:02 15:04:05", value, loc); err == nil { + m.exifData.TakenAt = tl.UTC() + } else { + log.Warnf("could parse time: %s", err.Error()) + } + } + m.exifData.All = tags return m.exifData, nil diff --git a/internal/photoprism/exif_test.go b/internal/photoprism/exif_test.go index 63d0c36b3..928f71218 100644 --- a/internal/photoprism/exif_test.go +++ b/internal/photoprism/exif_test.go @@ -22,6 +22,9 @@ func TestMediaFile_Exif_JPEG(t *testing.T) { assert.IsType(t, &Exif{}, info) assert.Equal(t, "Canon EOS 6D", info.CameraModel) + assert.Equal(t, "Africa/Johannesburg", info.TimeZone) + t.Logf("UTC: %s", info.TakenAt.String()) + t.Logf("Local: %s", info.TakenAtLocal.String()) } func TestMediaFile_Exif_DNG(t *testing.T) { diff --git a/internal/photoprism/indexer.go b/internal/photoprism/indexer.go index dcba8ebf1..8b4a5da9a 100644 --- a/internal/photoprism/indexer.go +++ b/internal/photoprism/indexer.go @@ -134,11 +134,6 @@ func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string { photo.PhotoPath = filePath photo.PhotoName = fileBase - if photo.TakenAt.IsZero() && photo.TakenAtChanged == false { - photo.TakenAt = mediaFile.DateCreated() - photo.TimeZone = mediaFile.TimeZone() - } - if jpeg, err := mediaFile.Jpeg(); err == nil { // Image classification labels labels = i.classifyImage(jpeg) @@ -147,6 +142,9 @@ func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string { if exifData, err := jpeg.Exif(); err == nil { photo.PhotoLat = exifData.Lat photo.PhotoLong = exifData.Long + photo.TakenAt = exifData.TakenAt + photo.TakenAtLocal = exifData.TakenAtLocal + photo.TimeZone = exifData.TimeZone photo.PhotoAltitude = exifData.Altitude photo.PhotoArtist = exifData.Artist @@ -167,6 +165,10 @@ func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string { photo.PhotoExposure = mediaFile.Exposure() } + if photo.TakenAt.IsZero() && photo.TakenAtChanged == false { + photo.TakenAt = mediaFile.DateCreated() + } + if location, err := mediaFile.Location(); err == nil { i.db.FirstOrCreate(location, "id = ?", location.ID) photo.Location = location @@ -240,9 +242,9 @@ func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string { if photo.PhotoTitleChanged == false && photo.PhotoTitle == "" { if len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 85 && labels[0].Name != "" { photo.PhotoTitle = fmt.Sprintf("%s / %s", util.Title(labels[0].Name), mediaFile.DateCreated().Format("2006")) - } else { + } else if !photo.TakenAtLocal.IsZero() { var daytimeString string - hour := mediaFile.DateCreated().Hour() + hour := photo.TakenAtLocal.Hour() switch { case hour < 17: @@ -253,7 +255,7 @@ func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string { daytimeString = "Unknown" } - photo.PhotoTitle = fmt.Sprintf("%s / %s", daytimeString, mediaFile.DateCreated().Format("2006")) + photo.PhotoTitle = fmt.Sprintf("%s / %s", daytimeString, photo.TakenAtLocal.Format("2006")) } } diff --git a/internal/photoprism/mediafile.go b/internal/photoprism/mediafile.go index b1bbc2e64..3fd649866 100644 --- a/internal/photoprism/mediafile.go +++ b/internal/photoprism/mediafile.go @@ -91,22 +91,6 @@ func (m *MediaFile) HasTimeAndPlace() bool { return result } -func (m *MediaFile) TimeZone() (result string) { - if m.timeZone != "" { - return m.timeZone - } - - exif, err := m.Exif() - - if err != nil || exif.TimeZone == "" { - result = "UTC" - } else { - result = exif.TimeZone - } - - return result -} - // CameraModel returns the camera model with which the media file was created. func (m *MediaFile) CameraModel() string { info, err := m.Exif() diff --git a/internal/photoprism/mediafile_test.go b/internal/photoprism/mediafile_test.go index 61163dbf5..08f8a459e 100644 --- a/internal/photoprism/mediafile_test.go +++ b/internal/photoprism/mediafile_test.go @@ -16,7 +16,7 @@ func TestMediaFile_DateCreated(t *testing.T) { mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic") assert.Nil(t, err) date := mediaFile.DateCreated().UTC() - assert.Equal(t, "2018-09-10 12:16:13 +0000 UTC", date.String()) + assert.Equal(t, "2018-09-10 03:16:13 +0000 UTC", date.String()) assert.Empty(t, err) }) t.Run("canon_eos_6d.dng", func(t *testing.T) { @@ -30,28 +30,11 @@ func TestMediaFile_DateCreated(t *testing.T) { mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg") assert.Nil(t, err) date := mediaFile.DateCreated().UTC() - assert.Equal(t, "2013-11-26 15:53:55 +0000 UTC", date.String()) + assert.Equal(t, "2013-11-26 13:53:55 +0000 UTC", date.String()) assert.Empty(t, err) }) } -func TestMediaFile_TimeZone(t *testing.T) { - t.Run("/beach_wood.jpg", func(t *testing.T) { - conf := config.TestConfig() - - mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg") - assert.Nil(t, err) - assert.Equal(t, "UTC", mediaFile.TimeZone()) - }) - t.Run("/iphone_7.heic", func(t *testing.T) { - conf := config.TestConfig() - - mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic") - assert.Nil(t, err) - assert.Equal(t, "UTC", mediaFile.TimeZone()) - }) -} - func TestMediaFile_HasTimeAndPlace(t *testing.T) { t.Run("/beach_wood.jpg", func(t *testing.T) { conf := config.TestConfig() @@ -209,7 +192,7 @@ func TestMediaFileCanonicalName(t *testing.T) { mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg") assert.Nil(t, err) - assert.Equal(t, "20180111_130938_EB4B2A989C20", mediaFile.CanonicalName()) + assert.Equal(t, "20180111_110938_EB4B2A989C20", mediaFile.CanonicalName()) } func TestMediaFileCanonicalNameFromFile(t *testing.T) { diff --git a/internal/photoprism/search_result.go b/internal/photoprism/search_result.go index 103a3b658..a9a5d3fde 100644 --- a/internal/photoprism/search_result.go +++ b/internal/photoprism/search_result.go @@ -10,6 +10,7 @@ type PhotoSearchResult struct { UpdatedAt time.Time DeletedAt time.Time TakenAt time.Time + TakenAtLocal time.Time TimeZone string PhotoPath string PhotoName string diff --git a/internal/photoprism/timezone.go b/internal/photoprism/timezone.go new file mode 100644 index 000000000..8dde7048d --- /dev/null +++ b/internal/photoprism/timezone.go @@ -0,0 +1,30 @@ +package photoprism + +import ( + "errors" + + "gopkg.in/ugjka/go-tz.v2/tz" +) + +// TimeZone returns the time zone where the photo was taken. +func (m *MediaFile) TimeZone() (string, error) { + meta, err := m.Exif() + + if err != nil { + return "UTC", errors.New("no image metadata") + } + + if meta.Lat == 0 && meta.Long == 0 { + return "UTC", errors.New("no latitude and longitude in image metadata") + } + + zones, err := tz.GetZone(tz.Point{ + Lon: meta.Long, Lat: meta.Lat, + }) + + if err != nil { + return "UTC", errors.New("no matching zone found") + } + + return zones[0], nil +} diff --git a/internal/photoprism/timezone_test.go b/internal/photoprism/timezone_test.go new file mode 100644 index 000000000..2514a9cd0 --- /dev/null +++ b/internal/photoprism/timezone_test.go @@ -0,0 +1,23 @@ +package photoprism + +import ( + "testing" + + "github.com/photoprism/photoprism/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestMediaFile_TimeZone(t *testing.T) { + t.Run("/elephants.jpg", func(t *testing.T) { + conf := config.TestConfig() + + img, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg") + + assert.Nil(t, err) + + zone, err := img.TimeZone() + + assert.Nil(t, err) + assert.Equal(t, "Africa/Johannesburg", zone) + }) +}