Refresh titles, labels and locations

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-04-18 23:20:54 +02:00
parent af3945d2fe
commit f07064c2c3
18 changed files with 1420 additions and 301 deletions

View file

@ -117,6 +117,7 @@
<v-flex xs12 sm6 md3 class="pa-2 p-timezone-select">
<v-autocomplete
@change="updateTime"
:disabled="disabled"
:label="labels.timezone"
hide-details
@ -198,10 +199,10 @@
hide-details
browser-autocomplete="off"
color="secondary-dark"
item-value="code"
item-text="name"
item-value="Code"
item-text="Name"
v-model="model.PhotoCountry"
:items="countryOptions">
:items="countries">
</v-select>
</v-flex>
@ -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);
}
});
},

View file

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

View file

@ -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)}}">
<v-img
:src="label.getThumbnailUrl('tile_500')"
aspect-ratio="1"

File diff suppressed because it is too large Load diff

View file

@ -52,7 +52,7 @@ func AddPhotoLabel(router *gin.RouterGroup, conf *config.Config) {
if plm.LabelUncertainty > 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

View file

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

View file

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

View file

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

View file

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

View file

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

9
internal/entity/src.go Normal file
View file

@ -0,0 +1,9 @@
package entity
const (
SrcAuto = ""
SrcManual = "manual"
SrcImg = "img"
SrcXmp = "xmp"
SrcYml = "yml"
)

View file

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

View file

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

View file

@ -965,7 +965,7 @@
},
{
"Code": "VN",
"Name": "Viet Nam"
"Name": "Vietnam"
},
{
"Code": "VG",

View file

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

View file

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

View file

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

View file

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