From f07064c2c39a47319c787c8ef0c9a4c4e9cbde13 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Sat, 18 Apr 2020 23:20:54 +0200 Subject: [PATCH] Refresh titles, labels and locations Signed-off-by: Michael Mayer --- frontend/src/dialog/photo/details.vue | 77 +- frontend/src/model/photo.js | 39 +- frontend/src/pages/labels.vue | 2 +- frontend/src/resources/countries.json | 1002 ++++++++++++++++++++++++ internal/api/photo_label.go | 4 +- internal/entity/label.go | 16 +- internal/entity/photo.go | 201 +++-- internal/entity/photo_label.go | 10 +- internal/entity/photo_location.go | 110 ++- internal/entity/photo_location_test.go | 43 + internal/entity/src.go | 9 + internal/form/photo.go | 53 +- internal/maps/countries.go | 2 +- internal/maps/countries.json | 2 +- internal/meta/exif.go | 2 +- internal/photoprism/index_location.go | 2 +- internal/photoprism/index_mediafile.go | 131 ++-- internal/query/photo.go | 16 +- 18 files changed, 1420 insertions(+), 301 deletions(-) create mode 100644 frontend/src/resources/countries.json create mode 100644 internal/entity/photo_location_test.go create mode 100644 internal/entity/src.go diff --git a/frontend/src/dialog/photo/details.vue b/frontend/src/dialog/photo/details.vue index 5449398cc..567d79425 100644 --- a/frontend/src/dialog/photo/details.vue +++ b/frontend/src/dialog/photo/details.vue @@ -117,6 +117,7 @@ + :items="countries"> @@ -376,6 +377,7 @@ import options from "resources/options.json"; import {DateTime} from "luxon"; import moment from "moment-timezone" + import countries from "resources/countries.json"; export default { name: 'p-tab-photo-edit-details', @@ -387,11 +389,11 @@ disabled: !this.$config.feature("edit"), config: this.$config.values, all: { - countries: [{code: "", name: ""}], colors: [{label: "Unknown", name: ""}], }, readonly: this.$config.getValue("readonly"), options: options, + countries: countries, labels: { search: this.$gettext("Search"), view: this.$gettext("View"), @@ -411,28 +413,48 @@ showTimePicker: false, date: "", time: "", + dateFormatted: "", + timeFormatted: "", + timeLocalFormatted: "", }; }, - computed: { - dateFormatted() { + watch: { + date() { if (!this.date) { - return ""; + this.dateFormatted = ""; + this.timeLocalFormatted = ""; + return } - return DateTime.fromISO(this.date).toLocaleString(DateTime.DATE_FULL); + this.dateFormatted = DateTime.fromISO(this.date).toLocaleString(DateTime.DATE_FULL); }, - timeFormatted() { - if (!this.time) { - return ""; - } - - return DateTime.fromISO(this.time).toLocaleString(DateTime.TIME_24_WITH_SECONDS); + time() { + this.updateTime(); }, - timeLocalFormatted() { + }, + computed: { + cameraOptions() { + return this.config.cameras; + }, + lensOptions() { + return this.config.lenses; + }, + colorOptions() { + return this.all.colors.concat(this.config.colors); + }, + timeZones() { + return moment.tz.names(); + }, + }, + methods: { + updateTime() { if (!this.time || !this.date) { - return "" + this.timeFormatted = "" + this.timeLocalFormatted = "" } + this.timeFormatted = DateTime.fromISO(this.time).toLocaleString(DateTime.TIME_24_WITH_SECONDS); + const utcDate = this.date + "T" + this.time + "Z"; this.model.TakenAt = utcDate; @@ -450,25 +472,8 @@ includeOffset: false, }) + "Z"; - return localDate.toLocaleString(DateTime.TIME_24_WITH_SECONDS); + this.timeLocalFormatted = localDate.toLocaleString(DateTime.TIME_24_WITH_SECONDS); }, - countryOptions() { - return this.all.countries.concat(this.config.countries); - }, - cameraOptions() { - return this.config.cameras; - }, - lensOptions() { - return this.config.lenses; - }, - colorOptions() { - return this.all.colors.concat(this.config.colors); - }, - timeZones() { - return moment.tz.names(); - }, - }, - methods: { left() { this.$emit('next'); }, @@ -485,6 +490,8 @@ const date = DateTime.fromISO(model.TakenAt).toUTC(); this.date = date.toISODate(); this.time = date.toFormat("HH:mm:ss"); + + this.updateTime(); } }, save(close) { @@ -495,6 +502,8 @@ this.model.update().then(() => { if (close) { this.$emit('close'); + } else { + this.refresh(this.model); } }); }, diff --git a/frontend/src/model/photo.js b/frontend/src/model/photo.js index 1372bc4fb..6d894ea78 100644 --- a/frontend/src/model/photo.js +++ b/frontend/src/model/photo.js @@ -2,47 +2,51 @@ import RestModel from "model/rest"; import Api from "common/api"; import {DateTime} from "luxon"; +const SrcAuto = "" +const SrcManual = "manual" +const SrcImg = "img" +const SrcXmp = "xmp" +const SrcYml = "yml" + class Photo extends RestModel { getDefaults() { return { ID: 0, TakenAt: "", + TakenAtLocal: "", + TakenSrc: "", + TimeZone: "", PhotoUUID: "", PhotoPath: "", PhotoName: "", PhotoTitle: "", + TitleSrc: "", PhotoFavorite: false, PhotoPrivate: false, PhotoNSFW: false, PhotoStory: false, + PhotoReview: false, PhotoLat: 0.0, PhotoLng: 0.0, PhotoAltitude: 0, - PhotoFocalLength: 0, PhotoIso: 0, + PhotoFocalLength: 0, PhotoFNumber: 0.0, PhotoExposure: "", PhotoViews: 0, Camera: {}, CameraID: 0, + CameraSrc: "", Lens: {}, LensID: 0, - CountryChanged: false, Location: null, LocationID: "", + LocationSrc: "", Place: null, PlaceID: "", - LocationEstimated: false, PhotoCountry: "", PhotoYear: 0, PhotoMonth: 0, - TakenAtLocal: "", - ModifiedTitle: false, - ModifiedDescription: false, - ModifiedDate: false, - ModifiedLocation: false, - ModifiedCamera: false, - TimeZone: "", Description: { PhotoDescription: "", PhotoKeywords: "", @@ -52,6 +56,7 @@ class Photo extends RestModel { PhotoCopyright: "", PhotoLicense: "", }, + DescriptionSrc: "", Files: [], Labels: [], Keywords: [], @@ -241,23 +246,23 @@ class Photo extends RestModel { const values = this.getValues(true); if(values.PhotoTitle) { - values.ModifiedTitle = true; + values.TitleSrc = SrcManual; } if(values.Description) { - values.ModifiedDescription = true; + values.DescriptionSrc = SrcManual; } - if(values.PhotoLat || values.PhotoLng || values.PhotoAltitude || values.PhotoCountry) { - values.ModifiedLocation = true; + if(values.PhotoLat || values.PhotoLng) { + values.LocationSrc = SrcManual; } if(values.TakenAt || values.TimeZone) { - values.ModifiedDate = true; + values.TakenSrc = SrcManual; } - if(values.CameraID || values.LensID) { - values.ModifiedCamera = true; + if(values.CameraID || values.LensID || values.PhotoFocalLength || values.PhotoFNumber || values.PhotoIso || values.PhotoExposure) { + values.CameraSrc = SrcManual; } return Api.put(this.getEntityResource(), values).then((response) => Promise.resolve(this.setValues(response.data))); diff --git a/frontend/src/pages/labels.vue b/frontend/src/pages/labels.vue index 2097ab05b..50c548ed2 100644 --- a/frontend/src/pages/labels.vue +++ b/frontend/src/pages/labels.vue @@ -65,7 +65,7 @@ slot-scope="{ hover }" :dark="selection.includes(label.LabelUUID)" :class="selection.includes(label.LabelUUID) ? 'elevation-10 ma-0 accent darken-1 white--text' : 'elevation-0 ma-1 accent lighten-3'" - :to="{name: 'photos', query: {q: 'label:' + label.CustomSlug}}"> + :to="{name: 'photos', query: {q: 'label:' + (label.CustomSlug ? label.CustomSlug : label.LabelSlug)}}"> f.LabelUncertainty { plm.LabelUncertainty = f.LabelUncertainty - plm.LabelSource = entity.LabelSourceManual + plm.LabelSource = entity.SrcManual if err := db.Save(&plm).Error; err != nil { log.Errorf("label: %s", err) @@ -112,7 +112,7 @@ func RemovePhotoLabel(router *gin.RouterGroup, conf *config.Config) { return } - if label.LabelSource == entity.LabelSourceManual { + if label.LabelSource == entity.SrcManual { db.Delete(&label) } else { label.LabelUncertainty = 100 diff --git a/internal/entity/label.go b/internal/entity/label.go index 77b5a289f..9c3b9affd 100644 --- a/internal/entity/label.go +++ b/internal/entity/label.go @@ -115,9 +115,19 @@ func (m *Label) Update(label classify.Label, db *gorm.DB) error { save = true } - if !save { - return nil + if save { + if err := db.Save(m).Error; err != nil { + return err + } } - return db.Save(m).Error + // Add categories + for _, category := range label.Categories { + sn := NewLabel(txt.Title(category), -3).FirstOrCreate(db) + if err := db.Model(m).Association("LabelCategories").Append(sn).Error; err != nil { + return err + } + } + + return nil } diff --git a/internal/entity/photo.go b/internal/entity/photo.go index b43a0c1de..652bf702d 100644 --- a/internal/entity/photo.go +++ b/internal/entity/photo.go @@ -1,12 +1,14 @@ package entity import ( + "errors" "fmt" "strings" "time" "github.com/jinzhu/gorm" "github.com/photoprism/photoprism/internal/classify" + "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/txt" @@ -15,52 +17,52 @@ import ( // Photo represents a photo, all its properties, and link to all its images and sidecar files. type Photo struct { - ID uint `gorm:"primary_key"` - TakenAt time.Time `gorm:"type:datetime;index:idx_photos_taken_uuid;" json:"TakenAt"` - PhotoUUID string `gorm:"type:varbinary(36);unique_index;index:idx_photos_taken_uuid;"` - PhotoPath string `gorm:"type:varbinary(512);index;"` - PhotoName string `gorm:"type:varbinary(256);"` - PhotoTitle string `json:"PhotoTitle"` - PhotoFavorite bool `json:"PhotoFavorite"` - PhotoPrivate bool `json:"PhotoPrivate"` - PhotoNSFW bool `json:"PhotoNSFW"` - PhotoStory bool `json:"PhotoStory"` - PhotoLat float64 `gorm:"index;" json:"PhotoLat"` - PhotoLng float64 `gorm:"index;" json:"PhotoLng"` - PhotoAltitude int `json:"PhotoAltitude"` - PhotoFocalLength int `json:"PhotoFocalLength"` - PhotoIso int `json:"PhotoIso"` - PhotoFNumber float64 `json:"PhotoFNumber"` - PhotoExposure string `gorm:"type:varbinary(64);" json:"PhotoExposure"` - CameraID uint `gorm:"index:idx_photos_camera_lens;" json:"CameraID"` - CameraSerial string `gorm:"type:varbinary(128);" json:"CameraSerial"` - LensID uint `gorm:"index:idx_photos_camera_lens;" json:"LensID"` - PlaceID string `gorm:"type:varbinary(16);index;" json:"PlaceID"` - LocationID string `gorm:"type:varbinary(16);index;" json:"LocationID"` - LocationEstimated bool `json:"LocationEstimated"` - PhotoCountry string `gorm:"index:idx_photos_country_year_month;" json:"PhotoCountry"` - PhotoYear int `gorm:"index:idx_photos_country_year_month;"` - PhotoMonth int `gorm:"index:idx_photos_country_year_month;"` - TimeZone string `gorm:"type:varbinary(64);" json:"TimeZone"` - TakenAtLocal time.Time `gorm:"type:datetime;"` - ModifiedTitle bool `json:"ModifiedTitle"` - ModifiedDescription bool `json:"ModifiedDescription"` - ModifiedDate bool `json:"ModifiedDate"` - ModifiedLocation bool `json:"ModifiedLocation"` - ModifiedCamera bool `json:"ModifiedCamera"` - Description Description `json:"Description"` - Camera *Camera `json:"Camera"` - Lens *Lens `json:"Lens"` - Location *Location `json:"Location"` - Place *Place `json:"-"` - Links []Link `gorm:"foreignkey:ShareUUID;association_foreignkey:PhotoUUID"` - Keywords []Keyword `json:"-"` - Albums []Album `json:"-"` - Files []File - Labels []PhotoLabel - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `sql:"index"` + ID uint `gorm:"primary_key"` + TakenAt time.Time `gorm:"type:datetime;index:idx_photos_taken_uuid;" json:"TakenAt"` + TakenSrc string `gorm:"type:varbinary(8);" json:"TakenSrc"` + PhotoUUID string `gorm:"type:varbinary(36);unique_index;index:idx_photos_taken_uuid;"` + PhotoPath string `gorm:"type:varbinary(512);index;"` + PhotoName string `gorm:"type:varbinary(256);"` + PhotoTitle string `json:"PhotoTitle"` + TitleSrc string `gorm:"type:varbinary(8);" json:"TitleSrc"` + PhotoFavorite bool `json:"PhotoFavorite"` + PhotoPrivate bool `json:"PhotoPrivate"` + PhotoNSFW bool `json:"PhotoNSFW"` + PhotoStory bool `json:"PhotoStory"` + PhotoReview bool `json:"PhotoReview"` + PhotoLat float64 `gorm:"index;" json:"PhotoLat"` + PhotoLng float64 `gorm:"index;" json:"PhotoLng"` + PhotoAltitude int `json:"PhotoAltitude"` + PhotoIso int `json:"PhotoIso"` + PhotoFocalLength int `json:"PhotoFocalLength"` + PhotoFNumber float64 `json:"PhotoFNumber"` + PhotoExposure string `gorm:"type:varbinary(64);" json:"PhotoExposure"` + CameraID uint `gorm:"index:idx_photos_camera_lens;" json:"CameraID"` + CameraSerial string `gorm:"type:varbinary(128);" json:"CameraSerial"` + CameraSrc string `gorm:"type:varbinary(8);" json:"CameraSrc"` + LensID uint `gorm:"index:idx_photos_camera_lens;" json:"LensID"` + PlaceID string `gorm:"type:varbinary(16);index;" json:"PlaceID"` + LocationID string `gorm:"type:varbinary(16);index;" json:"LocationID"` + LocationSrc string `gorm:"type:varbinary(8);" json:"LocationSrc"` + PhotoCountry string `gorm:"index:idx_photos_country_year_month;" json:"PhotoCountry"` + PhotoYear int `gorm:"index:idx_photos_country_year_month;"` + PhotoMonth int `gorm:"index:idx_photos_country_year_month;"` + TimeZone string `gorm:"type:varbinary(64);" json:"TimeZone"` + TakenAtLocal time.Time `gorm:"type:datetime;"` + Description Description `json:"Description"` + DescriptionSrc string `gorm:"type:varbinary(8);" json:"DescriptionSrc"` + Camera *Camera `json:"Camera"` + Lens *Lens `json:"Lens"` + Location *Location `json:"Location"` + Place *Place `json:"-"` + Links []Link `gorm:"foreignkey:ShareUUID;association_foreignkey:PhotoUUID"` + Keywords []Keyword `json:"-"` + Albums []Album `json:"-"` + Files []File + Labels []PhotoLabel + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `sql:"index"` } // SavePhotoForm updates a model using form data and persists it in the database. @@ -79,22 +81,23 @@ func SavePhotoForm(model Photo, form form.Photo, db *gorm.DB, geoApi string) err model.Description.PhotoKeywords = strings.Join(txt.UniqueKeywords(model.Description.PhotoKeywords), ", ") } - if model.HasLatLng() && locChanged && model.ModifiedLocation { + if model.HasLatLng() && locChanged && model.LocationSrc == SrcManual { + locKeywords, labels := model.UpdateLocation(db, geoApi) + + model.AddLabels(labels, db) + w := txt.UniqueKeywords(model.Description.PhotoKeywords) - - var locKeywords []string - labels := model.ClassifyLabels() - - locKeywords, labels = model.IndexLocation(db, geoApi, labels) - w = append(w, locKeywords...) - w = append(w, labels.Keywords()...) model.Description.PhotoKeywords = strings.Join(txt.UniqueWords(w), ", ") } + if err := model.UpdateTitle(model.ClassifyLabels()); err != nil { + log.Warnf("%s (%s)", err.Error(), model.PhotoUUID) + } + if err := model.IndexKeywords(db); err != nil { - log.Error(err) + log.Warnf("%s (%s)", err.Error(), model.PhotoUUID) } return db.Unscoped().Save(&model).Error @@ -305,3 +308,91 @@ func (m *Photo) HasTitle() bool { func (m *Photo) DescriptionLoaded() bool { return m.Description.PhotoID == m.ID } + +// UpdateTitle updated the photo title based on location and labels. +func (m *Photo) UpdateTitle(labels classify.Labels) error { + if m.TitleSrc != SrcAuto && m.HasTitle() { + return errors.New("photo: won't update title, was modified") + } + + m.TitleSrc = SrcAuto + + hasLocation := m.Location != nil && m.Location.Place != nil + + if hasLocation { + loc := m.Location + + if title := labels.Title(loc.Name()); title != "" { // TODO: User defined title format + log.Infof("photo: using label \"%s\" to create photo title", title) + if loc.NoCity() || loc.LongCity() || loc.CityContains(title) { + m.PhotoTitle = fmt.Sprintf("%s / %s / %s", txt.Title(title), loc.CountryName(), m.TakenAt.Format("2006")) + } else { + m.PhotoTitle = fmt.Sprintf("%s / %s / %s", txt.Title(title), loc.City(), m.TakenAt.Format("2006")) + } + } else if loc.Name() != "" && loc.City() != "" { + if len(loc.Name()) > 45 { + m.PhotoTitle = txt.Title(loc.Name()) + } else if len(loc.Name()) > 20 || len(loc.City()) > 16 || strings.Contains(loc.Name(), loc.City()) { + m.PhotoTitle = fmt.Sprintf("%s / %s", loc.Name(), m.TakenAt.Format("2006")) + } else { + m.PhotoTitle = fmt.Sprintf("%s / %s / %s", loc.Name(), loc.City(), m.TakenAt.Format("2006")) + } + } else if loc.City() != "" && loc.CountryName() != "" { + if len(loc.City()) > 20 { + m.PhotoTitle = fmt.Sprintf("%s / %s", loc.City(), m.TakenAt.Format("2006")) + } else { + m.PhotoTitle = fmt.Sprintf("%s / %s / %s", loc.City(), loc.CountryName(), m.TakenAt.Format("2006")) + } + } + } + + if m.NoTitle() { + if len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 85 && labels[0].Name != "" { + m.PhotoTitle = fmt.Sprintf("%s / %s", txt.Title(labels[0].Name), m.TakenAt.Format("2006")) + } else if !m.TakenAtLocal.IsZero() { + m.PhotoTitle = fmt.Sprintf("Unknown / %s", m.TakenAtLocal.Format("2006")) + } else { + m.PhotoTitle = "Unknown" + } + + log.Infof("photo: changed empty photo title to \"%s\"", m.PhotoTitle) + } else { + log.Infof("photo: new title is \"%s\"", m.PhotoTitle) + } + + return nil +} + +// AddLabels updates the entity with additional or updated label information. +func (m *Photo) AddLabels(labels classify.Labels, db *gorm.DB) { + // TODO: Update classify labels from database + for _, label := range labels { + lm := NewLabel(label.Title(), label.Priority).FirstOrCreate(db) + + if lm.New { + event.EntitiesCreated("labels", []*Label{lm}) + + if label.Priority >= 0 { + event.Publish("count.labels", event.Data{ + "count": 1, + }) + } + } + + if err := lm.Update(label, db); err != nil { + log.Errorf("index: %s", err) + } + + plm := NewPhotoLabel(m.ID, lm.ID, label.Uncertainty, label.Source).FirstOrCreate(db) + + if plm.LabelUncertainty > label.Uncertainty && plm.LabelUncertainty > 100 { + plm.LabelUncertainty = label.Uncertainty + plm.LabelSource = label.Source + if err := db.Save(&plm).Error; err != nil { + log.Errorf("index: %s", err) + } + } + } + + db.Set("gorm:auto_preload", true).Model(m).Related(&m.Labels) +} diff --git a/internal/entity/photo_label.go b/internal/entity/photo_label.go index 877b1d18f..9f0a4bb71 100644 --- a/internal/entity/photo_label.go +++ b/internal/entity/photo_label.go @@ -6,19 +6,15 @@ import ( "github.com/photoprism/photoprism/internal/mutex" ) -const ( - LabelSourceManual = "manual" -) - // PhotoLabel represents the many-to-many relation between Photo and label. // Labels are weighted by uncertainty (100 - confidence) type PhotoLabel struct { PhotoID uint `gorm:"primary_key;auto_increment:false"` LabelID uint `gorm:"primary_key;auto_increment:false;index"` LabelUncertainty int - LabelSource string - Photo *Photo - Label *Label + LabelSource string `gorm:"type:varbinary(8);"` + Photo *Photo `gorm:"PRELOAD:false"` + Label *Label `gorm:"PRELOAD:true"` } // TableName returns PhotoLabel table identifier "photos_labels" diff --git a/internal/entity/photo_location.go b/internal/entity/photo_location.go index 5d2b12f93..bdb0b0d99 100644 --- a/internal/entity/photo_location.go +++ b/internal/entity/photo_location.go @@ -1,25 +1,54 @@ package entity import ( - "errors" - "fmt" - "strings" + "time" "github.com/jinzhu/gorm" "github.com/photoprism/photoprism/internal/classify" "github.com/photoprism/photoprism/internal/event" - "github.com/photoprism/photoprism/pkg/txt" + "gopkg.in/ugjka/go-tz.v2/tz" ) -// IndexLocation updates location and labels based on latitude and longitude. -func (m *Photo) IndexLocation(db *gorm.DB, geoApi string, labels classify.Labels) ([]string, classify.Labels) { +// GetTimeZone uses PhotoLat and PhotoLng to guess the time zone of the photo. +func (m *Photo) GetTimeZone() string { + result := "UTC" + + if m.HasLatLng() { + zones, err := tz.GetZone(tz.Point{ + Lat: m.PhotoLat, + Lon: m.PhotoLng, + }) + + if err == nil && len(zones) > 0 { + result = zones[0] + } + } + + return result +} + +// GetTakenAt returns UTC time for TakenAtLocal. +func (m *Photo) GetTakenAt() time.Time { + loc, err := time.LoadLocation(m.TimeZone) + + if err != nil { + return m.TakenAt + } + + if takenAt, err := time.ParseInLocation("2006-01-02T15:04:05", m.TakenAtLocal.Format("2006-01-02T15:04:05"), loc); err != nil { + return m.TakenAt + } else { + return takenAt.UTC() + } +} + +// UpdateLocation updates location and labels based on latitude and longitude. +func (m *Photo) UpdateLocation(db *gorm.DB, geoApi string) (keywords []string, labels classify.Labels) { var location = NewLocation(m.PhotoLat, m.PhotoLng) location.Lock() defer location.Unlock() - var keywords []string - err := location.Find(db, geoApi) if err == nil { @@ -33,7 +62,12 @@ func (m *Photo) IndexLocation(db *gorm.DB, geoApi string, labels classify.Labels m.LocationID = location.ID m.Place = location.Place m.PlaceID = location.PlaceID - m.LocationEstimated = false + m.PhotoCountry = location.CountryCode() + + if m.TakenSrc != SrcManual { + m.TimeZone = m.GetTimeZone() + m.TakenAt = m.GetTakenAt() + } country := NewCountry(location.CountryCode(), location.CountryName()).FirstOrCreate(db) @@ -50,10 +84,6 @@ func (m *Photo) IndexLocation(db *gorm.DB, geoApi string, labels classify.Labels if locCategory != "" { labels = append(labels, classify.LocationLabel(locCategory, 0, -1)) } - - if err := m.UpdateTitle(labels); err != nil { - log.Warn(err) - } } else { log.Warn(err) @@ -61,61 +91,9 @@ func (m *Photo) IndexLocation(db *gorm.DB, geoApi string, labels classify.Labels m.PlaceID = UnknownPlace.ID } - if m.Place != nil && (!m.ModifiedLocation || m.PhotoCountry == "" || m.PhotoCountry == "zz") { + if m.Place != nil && (m.PhotoCountry == "" || m.PhotoCountry == "zz") { m.PhotoCountry = m.Place.LocCountry } return keywords, labels } - -// UpdateTitle updated the photo title based on location and labels. -func (m *Photo) UpdateTitle(labels classify.Labels) error { - if m.ModifiedTitle && m.HasTitle() { - return errors.New("photo: won't update title, was modified") - } - - hasLocation := m.Location != nil && m.Location.Place != nil - - if hasLocation { - loc := m.Location - - if title := labels.Title(loc.Name()); title != "" { // TODO: User defined title format - log.Infof("photo: using label \"%s\" to create photo title", title) - if loc.NoCity() || loc.LongCity() || loc.CityContains(title) { - m.PhotoTitle = fmt.Sprintf("%s / %s / %s", txt.Title(title), loc.CountryName(), m.TakenAt.Format("2006")) - } else { - m.PhotoTitle = fmt.Sprintf("%s / %s / %s", txt.Title(title), loc.City(), m.TakenAt.Format("2006")) - } - } else if loc.Name() != "" && loc.City() != "" { - if len(loc.Name()) > 45 { - m.PhotoTitle = txt.Title(loc.Name()) - } else if len(loc.Name()) > 20 || len(loc.City()) > 16 || strings.Contains(loc.Name(), loc.City()) { - m.PhotoTitle = fmt.Sprintf("%s / %s", loc.Name(), m.TakenAt.Format("2006")) - } else { - m.PhotoTitle = fmt.Sprintf("%s / %s / %s", loc.Name(), loc.City(), m.TakenAt.Format("2006")) - } - } else if loc.City() != "" && loc.CountryName() != "" { - if len(loc.City()) > 20 { - m.PhotoTitle = fmt.Sprintf("%s / %s", loc.City(), m.TakenAt.Format("2006")) - } else { - m.PhotoTitle = fmt.Sprintf("%s / %s / %s", loc.City(), loc.CountryName(), m.TakenAt.Format("2006")) - } - } - } - - if !hasLocation || m.NoTitle() { - if len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 85 && labels[0].Name != "" { - m.PhotoTitle = fmt.Sprintf("%s / %s", txt.Title(labels[0].Name), m.TakenAt.Format("2006")) - } else if !m.TakenAtLocal.IsZero() { - m.PhotoTitle = fmt.Sprintf("Unknown / %s", m.TakenAtLocal.Format("2006")) - } else { - m.PhotoTitle = "Unknown" - } - - log.Infof("photo: changed empty photo title to \"%s\"", m.PhotoTitle) - } else { - log.Infof("photo: new title is \"%s\"", m.PhotoTitle) - } - - return nil -} diff --git a/internal/entity/photo_location_test.go b/internal/entity/photo_location_test.go new file mode 100644 index 000000000..871527d64 --- /dev/null +++ b/internal/entity/photo_location_test.go @@ -0,0 +1,43 @@ +package entity + +import ( + "testing" + "time" +) + +func TestPhoto_GetTimeZone(t *testing.T) { + m := Photo{} + m.PhotoLat = 48.533905555 + m.PhotoLng = 9.01 + + result := m.GetTimeZone() + + if result != "Europe/Berlin" { + t.Fatalf("time zone should be Europe/Berlin: %s", result) + } +} + +func TestPhoto_GetTakenAt(t *testing.T) { + m := Photo{} + m.PhotoLat = 48.533905555 + m.PhotoLng = 9.01 + m.TakenAt, _ = time.Parse(time.RFC3339, "2020-02-04T11:54:34Z") + m.TakenAtLocal, _ = time.Parse(time.RFC3339, "2020-02-04T11:54:34Z") + m.TimeZone = m.GetTimeZone() + + if m.TimeZone != "Europe/Berlin" { + t.Fatalf("time zone should be Europe/Berlin: %s", m.TimeZone) + } + + localTime := m.TakenAtLocal.Format("2006-01-02T15:04:05") + + if localTime != "2020-02-04T11:54:34" { + t.Fatalf("local time should be 2020-02-04T11:54:34: %s", localTime) + } + + 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) + } +} diff --git a/internal/entity/src.go b/internal/entity/src.go new file mode 100644 index 000000000..62b870b75 --- /dev/null +++ b/internal/entity/src.go @@ -0,0 +1,9 @@ +package entity + +const ( + SrcAuto = "" + SrcManual = "manual" + SrcImg = "img" + SrcXmp = "xmp" + SrcYml = "yml" +) diff --git a/internal/form/photo.go b/internal/form/photo.go index 3655af433..960aacc29 100644 --- a/internal/form/photo.go +++ b/internal/form/photo.go @@ -8,9 +8,13 @@ import ( // Photo represents a photo edit form. type Photo struct { - TakenAt time.Time `json:"TakenAt"` - PhotoTitle string `json:"PhotoTitle"` - Description struct { + TakenAt time.Time `json:"TakenAt"` + TakenAtLocal time.Time `json:"TakenAtLocal"` + TakenSrc string `json:"TakenSrc"` + TimeZone string `json:"TimeZone"` + PhotoTitle string `json:"PhotoTitle"` + TitleSrc string `json:"TitleSrc"` + Description struct { PhotoID uint `json:"PhotoID" deepcopier:"skip"` PhotoDescription string `json:"PhotoDescription"` PhotoKeywords string `json:"PhotoKeywords"` @@ -20,29 +24,26 @@ type Photo struct { PhotoCopyright string `json:"PhotoCopyright"` PhotoLicense string `json:"PhotoLicense"` } `json:"Description"` - PhotoFavorite bool `json:"PhotoFavorite"` - PhotoPrivate bool `json:"PhotoPrivate"` - PhotoNSFW bool `json:"PhotoNSFW"` - PhotoStory bool `json:"PhotoStory"` - PhotoLat float64 `json:"PhotoLat"` - PhotoLng float64 `json:"PhotoLng"` - PhotoAltitude int `json:"PhotoAltitude"` - PhotoFocalLength int `json:"PhotoFocalLength"` - PhotoIso int `json:"PhotoIso"` - PhotoFNumber float64 `json:"PhotoFNumber"` - PhotoExposure string `json:"PhotoExposure"` - CameraID uint `json:"CameraID"` - LensID uint `json:"LensID"` - LocationID string `json:"LocationID"` - PlaceID string `json:"PlaceID"` - PhotoCountry string `json:"PhotoCountry"` - TimeZone string `json:"TimeZone"` - TakenAtLocal time.Time `json:"TakenAtLocal"` - ModifiedTitle bool `json:"ModifiedTitle"` - ModifiedDescription bool `json:"ModifiedDescription"` - ModifiedDate bool `json:"ModifiedDate"` - ModifiedLocation bool `json:"ModifiedLocation"` - ModifiedCamera bool `json:"ModifiedCamera"` + DescriptionSrc string `json:"DescriptionSrc"` + PhotoFavorite bool `json:"PhotoFavorite"` + PhotoPrivate bool `json:"PhotoPrivate"` + PhotoNSFW bool `json:"PhotoNSFW"` + PhotoStory bool `json:"PhotoStory"` + PhotoReview bool `json:"PhotoReview"` + PhotoLat float64 `json:"PhotoLat"` + PhotoLng float64 `json:"PhotoLng"` + PhotoAltitude int `json:"PhotoAltitude"` + PhotoIso int `json:"PhotoIso"` + PhotoFocalLength int `json:"PhotoFocalLength"` + PhotoFNumber float64 `json:"PhotoFNumber"` + PhotoExposure string `json:"PhotoExposure"` + CameraID uint `json:"CameraID"` + CameraSrc string `json:"CameraSrc"` + LensID uint `json:"LensID"` + LocationID string `json:"LocationID"` + LocationSrc string `json:"LocationSrc"` + PlaceID string `json:"PlaceID"` + PhotoCountry string `json:"PhotoCountry"` } func NewPhoto(m interface{}) (f Photo, err error) { diff --git a/internal/maps/countries.go b/internal/maps/countries.go index 8ac9abfbc..c569c76e9 100644 --- a/internal/maps/countries.go +++ b/internal/maps/countries.go @@ -243,7 +243,7 @@ var CountryNames = map[string]string{ "uz": "Uzbekistan", "vu": "Vanuatu", "ve": "Venezuela", - "vn": "Viet Nam", + "vn": "Vietnam", "vg": "British Virgin Islands", "vi": "US Virgin Islands", "wf": "Wallis and Futuna", diff --git a/internal/maps/countries.json b/internal/maps/countries.json index 06bce845b..ff3606c26 100644 --- a/internal/maps/countries.json +++ b/internal/maps/countries.json @@ -965,7 +965,7 @@ }, { "Code": "VN", - "Name": "Viet Nam" + "Name": "Vietnam" }, { "Code": "VG", diff --git a/internal/meta/exif.go b/internal/meta/exif.go index 4ac7e5f33..1a2f810ae 100644 --- a/internal/meta/exif.go +++ b/internal/meta/exif.go @@ -273,7 +273,7 @@ func Exif(filename string) (data Data, err error) { } else if tl, err := time.ParseInLocation("2006:01:02 15:04:05", value, loc); err == nil { data.TakenAt = tl.UTC() } else { - log.Warnf("could parse time: %s", err.Error()) + log.Warnf("could not parse time: %s", err.Error()) } } diff --git a/internal/photoprism/index_location.go b/internal/photoprism/index_location.go index 21d723150..eac569836 100644 --- a/internal/photoprism/index_location.go +++ b/internal/photoprism/index_location.go @@ -12,7 +12,7 @@ func (ind *Index) estimateLocation(photo *entity.Photo) { if recentPhoto.HasPlace() { photo.Place = recentPhoto.Place photo.PhotoCountry = photo.Place.LocCountry - photo.LocationEstimated = true + photo.LocationSrc = entity.SrcAuto log.Debugf("index: approximate location is \"%s\"", recentPhoto.Place.Label()) } } diff --git a/internal/photoprism/index_mediafile.go b/internal/photoprism/index_mediafile.go index 026b45838..342eabdd1 100644 --- a/internal/photoprism/index_mediafile.go +++ b/internal/photoprism/index_mediafile.go @@ -149,24 +149,28 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( if fileChanged || o.UpdateExif { // Read UpdateExif data if metaData, err := m.MetaData(); err == nil { - if !photo.ModifiedLocation { + if photo.LocationSrc == entity.SrcAuto || photo.LocationSrc == entity.SrcImg { photo.PhotoLat = metaData.Lat photo.PhotoLng = metaData.Lng photo.PhotoAltitude = metaData.Altitude + photo.LocationSrc = entity.SrcImg } - if !photo.ModifiedDate { + if photo.TakenSrc == entity.SrcAuto || photo.TakenSrc == entity.SrcImg { photo.TakenAt = metaData.TakenAt photo.TakenAtLocal = metaData.TakenAtLocal photo.TimeZone = metaData.TimeZone + photo.TakenSrc = entity.SrcImg } - if photo.NoTitle() { + if metaData.Title != "" && (photo.NoTitle() || photo.TitleSrc == entity.SrcImg) { photo.PhotoTitle = metaData.Title + photo.TitleSrc = entity.SrcImg } - if photo.Description.NoDescription() { + if metaData.Description != "" && (photo.Description.NoDescription() || photo.DescriptionSrc == entity.SrcImg) { photo.Description.PhotoDescription = metaData.Description + photo.DescriptionSrc = entity.SrcImg } if photo.Description.NoNotes() { @@ -201,7 +205,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( } } - if !photo.ModifiedCamera && (fileChanged || o.UpdateCamera) { + if photo.CameraSrc == entity.SrcAuto && (fileChanged || o.UpdateCamera) { // Set UpdateCamera, Lens, Focal Length and F Number photo.Camera = entity.NewCamera(m.CameraModel(), m.CameraMake()).FirstOrCreate(ind.db) photo.Lens = entity.NewLens(m.LensModel(), m.LensMake()).FirstOrCreate(ind.db) @@ -218,14 +222,12 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( if fileChanged || o.UpdateKeywords || o.UpdateLocation || o.UpdateTitle || photo.NoTitle() { if photo.HasLatLng() { - locKeywords, labels = photo.IndexLocation(ind.db, ind.conf.GeoCodingApi(), labels) + var locLabels classify.Labels + locKeywords, locLabels = photo.UpdateLocation(ind.db, ind.conf.GeoCodingApi()) + labels = append(labels, locLabels...) } else { log.Info("index: no latitude and longitude in metadata") - if err := photo.UpdateTitle(labels); err != nil { - log.Warn(err) - } - photo.Place = entity.UnknownPlace photo.PlaceID = entity.UnknownPlace.ID } @@ -233,8 +235,9 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( } else if m.IsXMP() { // TODO: Proof-of-concept for indexing XMP sidecar files if data, err := meta.XMP(m.FileName()); err == nil { - if data.Title != "" && !photo.ModifiedTitle { + if data.Title != "" && photo.TitleSrc == entity.SrcAuto { photo.PhotoTitle = data.Title + photo.TitleSrc = entity.SrcXmp } if photo.Description.NoCopyright() && data.Copyright != "" { @@ -255,6 +258,10 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( } } + if photo.Place != nil && (photo.PhotoCountry == "" || photo.PhotoCountry == "zz") { + photo.PhotoCountry = photo.Place.LocCountry + } + if !photo.TakenAtLocal.IsZero() { photo.PhotoYear = photo.TakenAtLocal.Year() photo.PhotoMonth = int(photo.TakenAtLocal.Month()) @@ -297,28 +304,6 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( } } - if file.FilePrimary && (fileChanged || o.UpdateKeywords) { - w := txt.Keywords(photo.Description.PhotoKeywords) - - if NonCanonical(fileBase) { - w = append(w, txt.FilenameKeywords(filePath)...) - w = append(w, txt.FilenameKeywords(fileBase)...) - } - - w = append(w, locKeywords...) - w = append(w, txt.FilenameKeywords(file.OriginalName)...) - w = append(w, file.FileMainColor) - w = append(w, labels.Keywords()...) - - photo.Description.PhotoKeywords = strings.Join(txt.UniqueWords(w), ", ") - - if photo.Description.PhotoKeywords != "" { - log.Debugf("index: updated photo keywords (%s)", photo.Description.PhotoKeywords) - } else { - log.Debug("index: no photo keywords") - } - } - if photoExists { // Estimate location if o.UpdateLocation && photo.NoLocation() { @@ -348,10 +333,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( event.EntitiesCreated("photos", []entity.Photo{photo}) } - if len(labels) > 0 { - log.Infof("index: adding labels %+v", labels) - ind.addLabels(photo.ID, labels) - } + photo.AddLabels(labels, ind.db) file.PhotoID = photo.ID result.PhotoID = photo.ID @@ -359,9 +341,42 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( file.PhotoUUID = photo.PhotoUUID result.PhotoUUID = photo.PhotoUUID - if file.FilePrimary && (fileChanged || o.UpdateKeywords) { + if file.FilePrimary && (fileChanged || o.UpdateKeywords || o.UpdateTitle || o.UpdateLabels) { + labels := photo.ClassifyLabels() + + if err := photo.UpdateTitle(labels); err != nil { + log.Warnf("%s (%s)", err.Error(), photo.PhotoUUID) + } + + w := txt.Keywords(photo.Description.PhotoKeywords) + + if NonCanonical(fileBase) { + w = append(w, txt.FilenameKeywords(filePath)...) + w = append(w, txt.FilenameKeywords(fileBase)...) + } + + w = append(w, locKeywords...) + w = append(w, txt.FilenameKeywords(file.OriginalName)...) + w = append(w, file.FileMainColor) + w = append(w, labels.Keywords()...) + + photo.Description.PhotoKeywords = strings.Join(txt.UniqueWords(w), ", ") + + if photo.Description.PhotoKeywords != "" { + log.Debugf("index: updated photo keywords (%s)", photo.Description.PhotoKeywords) + } else { + log.Debug("index: no photo keywords") + } + + if err := ind.db.Unscoped().Save(&photo).Error; err != nil { + log.Errorf("index: %s", err) + result.Status = IndexFailed + result.Error = err + return result + } + if err := photo.IndexKeywords(ind.db); err != nil { - log.Error(err) + log.Warnf("%s (%s)", err.Error(), photo.PhotoUUID) } } @@ -484,41 +499,3 @@ func (ind *Index) classifyImage(jpeg *MediaFile) (results classify.Labels) { return results } - -func (ind *Index) addLabels(photoId uint, labels classify.Labels) { - for _, label := range labels { - lm := entity.NewLabel(label.Title(), label.Priority).FirstOrCreate(ind.db) - - if lm.New { - event.EntitiesCreated("labels", []*entity.Label{lm}) - - if label.Priority >= 0 { - event.Publish("count.labels", event.Data{ - "count": 1, - }) - } - } - - if err := lm.Update(label, ind.db); err != nil { - log.Errorf("index: %s", err) - } - - plm := entity.NewPhotoLabel(photoId, lm.ID, label.Uncertainty, label.Source).FirstOrCreate(ind.db) - - // Add categories - for _, category := range label.Categories { - sn := entity.NewLabel(txt.Title(category), -3).FirstOrCreate(ind.db) - if err := ind.db.Model(&lm).Association("LabelCategories").Append(sn).Error; err != nil { - log.Errorf("index: %s", err) - } - } - - if plm.LabelUncertainty > label.Uncertainty && plm.LabelUncertainty > 100 { - plm.LabelUncertainty = label.Uncertainty - plm.LabelSource = label.Source - if err := ind.db.Save(&plm).Error; err != nil { - log.Errorf("index: %s", err) - } - } - } -} diff --git a/internal/query/photo.go b/internal/query/photo.go index d1317cc3b..5e791ce64 100644 --- a/internal/query/photo.go +++ b/internal/query/photo.go @@ -36,8 +36,8 @@ type PhotoResult struct { PhotoLat float64 PhotoLng float64 PhotoAltitude int - PhotoFocalLength int PhotoIso int + PhotoFocalLength int PhotoFNumber float64 PhotoExposure string @@ -52,14 +52,12 @@ type PhotoResult struct { LensMake string // Location - LocationID string - PlaceID string - LocLabel string - LocCity string - LocState string - LocCountry string - LocationChanged bool - LocationEstimated bool + LocationID string + PlaceID string + LocLabel string + LocCity string + LocState string + LocCountry string // File FileID uint