Implement time zone support for "TakenAt"

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2019-09-19 14:23:39 -07:00
parent a2bf11c96f
commit 0f47c84138
24 changed files with 169 additions and 104 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@
</v-btn>
</td>
<td @click="openPhoto(props.index)" class="p-pointer">{{ props.item.PhotoTitle }}</td>
<td>{{ props.item.TakenAt | moment('DD/MM/YYYY hh:mm:ss') }}</td>
<td>{{ props.item.TakenAt | luxon:format('dd/MM/yyyy hh:mm:ss') }}</td>
<td @click="openLocation(props.index)" class="p-pointer">{{ props.item.LocCountry }}</td>
<td>{{ props.item.CameraMake }} {{ props.item.CameraModel }}</td>
<td><v-btn icon small flat :ripple="false"

View file

@ -1,6 +1,6 @@
import Abstract from "model/abstract";
import Api from "common/api";
import moment from "moment";
import { DateTime } from "luxon";
class Album extends Abstract {
getEntityName() {
@ -44,7 +44,7 @@ class Album extends Abstract {
}
getDateString() {
return moment(this.CreatedAt).format("LLL");
return DateTime.fromISO(this.CreatedAt).toLocaleString(DateTime.DATETIME_MED);
}
toggleLike() {

View file

@ -1,6 +1,6 @@
import Abstract from "model/abstract";
import Api from "common/api";
import moment from "moment";
import { DateTime } from "luxon";
class Label extends Abstract {
getEntityName() {
@ -44,7 +44,7 @@ class Label extends Abstract {
}
getDateString() {
return moment(this.CreatedAt).format("LLL");
return DateTime.fromISO(this.CreatedAt).toLocaleString(DateTime.DATETIME_MED);
}
toggleLike() {

View file

@ -1,7 +1,7 @@
import Abstract from "model/abstract";
import Api from "common/api";
import truncate from "truncate";
import moment from "moment";
import { DateTime } from "luxon";
class Photo extends Abstract {
getEntityName() {
@ -91,7 +91,13 @@ class Photo extends Abstract {
}
getDateString() {
return moment(this.TakenAt).format("LLL");
if(this.TimeZone) {
return DateTime.fromISO(this.TakenAt).setZone(this.TimeZone).toLocaleString(DateTime.DATETIME_FULL);
} else if(this.TakenAt) {
return DateTime.fromISO(this.TakenAt).toLocaleString(DateTime.DATE_HUGE);
} else {
return "Unknown";
}
}
hasLocation() {

View file

@ -171,7 +171,7 @@
></v-checkbox>
</td>
<td>{{ props.item.PhotoTitle }}</td>
<td>{{ props.item.TakenAt | moment('DD/MM/YYYY hh:mm:ss') }}</td>
<td>{{ props.item.TakenAt | luxon:format('dd/MM/yyyy hh:mm:ss') }}</td>
<td>{{ props.item.LocCity }}</td>
<td>{{ props.item.LocCountry }}</td>
<td>{{ props.item.CameraModel }}</td>
@ -240,7 +240,7 @@
truncate(80) }}</h3>
<div class="caption">
<v-icon size="14">date_range</v-icon>
{{ photo.TakenAt | moment('DD/MM/YYYY hh:mm:ss') }}
{{ photo.TakenAt | luxon:format('dd/MM/yyyy hh:mm:ss') }}
<br/>
<v-icon size="14">photo_camera</v-icon>
{{ photo.getCamera() }}

View file

@ -175,4 +175,4 @@ describe("common/clipboard", () => {
assert.equal(clipboard.selection, "");
});
});
});

View file

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

View file

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

View file

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

1
go.mod
View file

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

4
go.sum
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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