Places: Sort States by Country Name and Title #1608 #1740

This commit is contained in:
Michael Mayer 2021-11-20 16:36:34 +01:00
parent df1ffc68cf
commit 06c23b0cb3
15 changed files with 175 additions and 73 deletions

View file

@ -206,6 +206,9 @@ func (c *Config) Init() error {
log.Infof("config: make sure your server has enough swap configured to prevent restarts when there are memory usage spikes")
}
// Set User Agent for HTTP requests.
places.UserAgent = fmt.Sprintf("%s/%s", c.Name(), c.Version())
c.initSettings()
c.initHub()

View file

@ -9,6 +9,8 @@ import (
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/maps"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/rnd"
@ -308,7 +310,7 @@ func (m *Album) SetTitle(title string) {
m.AlbumTitle = txt.Clip(title, txt.ClipDefault)
if m.AlbumType == AlbumDefault {
if m.AlbumType == AlbumDefault || m.AlbumSlug == "" {
if len(m.AlbumTitle) < txt.ClipSlug {
m.AlbumSlug = txt.Slug(m.AlbumTitle)
} else {
@ -321,6 +323,39 @@ func (m *Album) SetTitle(title string) {
}
}
// UpdateState updates the album location.
func (m *Album) UpdateState(stateName, countryCode string) error {
if stateName == "" || countryCode == "" {
return nil
}
changed := false
countryName := maps.CountryName(countryCode)
if m.AlbumCountry != countryCode {
m.AlbumCountry = countryCode
changed = true
}
if changed || m.AlbumLocation == "" {
m.AlbumLocation = countryName
changed = true
}
if m.AlbumState != stateName {
m.AlbumState = stateName
changed = true
}
if !changed {
return nil
}
m.AlbumTitle = stateName
return m.Updates(Values{"album_title": m.AlbumTitle, "album_location": m.AlbumLocation, "album_country": m.AlbumCountry, "album_state": m.AlbumState})
}
// SaveForm updates the entity using form data and stores it in the database.
func (m *Album) SaveForm(f form.Album) error {
if err := deepcopier.Copy(m).From(f); err != nil {
@ -343,6 +378,11 @@ func (m *Album) Update(attr string, value interface{}) error {
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
}
// Updates multiple columns in the database.
func (m *Album) Updates(values interface{}) error {
return UnscopedDb().Model(m).UpdateColumns(values).Error
}
// UpdateFolder updates the path, filter and slug for a folder album.
func (m *Album) UpdateFolder(albumPath, albumFilter string) error {
albumPath = strings.Trim(albumPath, string(os.PathSeparator))
@ -413,6 +453,31 @@ func (m *Album) Delete() error {
return nil
}
// DeletePermanently permanently removes an album from the index.
func (m *Album) DeletePermanently() error {
albumType := m.AlbumType
wasDeleted := m.Deleted()
if err := UnscopedDb().Delete(m).Error; err != nil {
return err
}
if !wasDeleted {
switch albumType {
case AlbumDefault:
event.Publish("count.albums", event.Data{"count": -1})
case AlbumMoment:
event.Publish("count.moments", event.Data{"count": -1})
case AlbumMonth:
event.Publish("count.months", event.Data{"count": -1})
case AlbumFolder:
event.Publish("count.folders", event.Data{"count": -1})
}
}
return nil
}
// Deleted tests if the entity is deleted.
func (m *Album) Deleted() bool {
return m.DeletedAt != nil

View file

@ -4,11 +4,11 @@ import (
"testing"
"time"
"github.com/photoprism/photoprism/internal/form"
"github.com/gosimple/slug"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/txt"
)
func TestNewAlbum(t *testing.T) {
@ -69,6 +69,33 @@ is an oblate spheroid.`
})
}
func TestAlbum_UpdateState(t *testing.T) {
t.Run("success", func(t *testing.T) {
album := NewAlbum("Any State", AlbumState)
assert.Equal(t, "Any State", album.AlbumTitle)
assert.Equal(t, "any-state", album.AlbumSlug)
if err := album.Create(); err != nil {
t.Fatal(err)
}
if err := album.UpdateState("Alberta", "ca"); err != nil {
t.Fatal(err)
}
assert.Equal(t, "Alberta", album.AlbumTitle)
assert.Equal(t, "", album.AlbumDescription)
assert.Equal(t, "Canada", album.AlbumLocation)
assert.Equal(t, "Alberta", album.AlbumState)
assert.Equal(t, "ca", album.AlbumCountry)
if err := album.DeletePermanently(); err != nil {
t.Fatal(err)
}
})
}
func TestAlbum_SaveForm(t *testing.T) {
t.Run("success", func(t *testing.T) {
album := NewAlbum("Old Name", AlbumDefault)

View file

@ -7,14 +7,13 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/maps"
"github.com/photoprism/photoprism/pkg/s2"
"github.com/photoprism/photoprism/pkg/txt"
)
var cellMutex = sync.Mutex{}
// Cell represents a S2 cell with location data.
// Cell represents an S2 cell with metadata and reference to a place.
type Cell struct {
ID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"ID" yaml:"ID"`
CellName string `gorm:"type:VARCHAR(200);" json:"Name" yaml:"Name,omitempty"`
@ -142,14 +141,14 @@ func (m *Cell) Find(api string) error {
}
l := &maps.Location{
ID: s2.NormalizeToken(m.ID),
ID: m.ID,
}
if err := l.QueryApi(api); err != nil {
return err
}
if found := FindPlace(l.PlaceID(), l.Label()); found != nil {
if found := FindPlace(l.PlaceID()); found != nil {
m.Place = found
} else {
place := &Place{
@ -171,7 +170,7 @@ func (m *Cell) Find(api string) error {
log.Infof("place: added %s [%s]", place.ID, time.Since(start))
m.Place = place
} else if found := FindPlace(l.PlaceID(), l.Label()); found != nil {
} else if found := FindPlace(l.PlaceID()); found != nil {
m.Place = found
} else {
log.Errorf("place: %s (create %s)", createErr, place.ID)

View file

@ -4,6 +4,7 @@ import (
"time"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -55,10 +56,19 @@ func (m *Photo) EstimatePlace(force bool) {
return
}
// Only estimate country if date isn't known with certainty.
if m.TakenSrc == SrcAuto {
m.PlaceID = UnknownPlace.ID
m.PlaceSrc = SrcEstimate
m.EstimateCountry()
m.EstimatedAt = TimePointer()
return
}
var err error
rangeMin := m.TakenAt.AddDate(0, 0, -1)
rangeMax := m.TakenAt.AddDate(0, 0, 1)
rangeMin := m.TakenAt.Add(-1 * time.Hour * 72)
rangeMax := m.TakenAt.Add(time.Hour * 72)
// Find photo with location info taken at a similar time...
var recentPhoto Photo

View file

@ -63,7 +63,7 @@ func TestPhoto_EstimateCountry(t *testing.T) {
func TestPhoto_EstimatePlace(t *testing.T) {
t.Run("photo already has location", func(t *testing.T) {
p := &Place{ID: "1000000001", PlaceCountry: "mx"}
m := Photo{PhotoName: "PhotoWithLocation", OriginalName: "demo/xyz.jpg", Place: p, PlaceID: "1000000001", PlaceSrc: SrcManual, PhotoCountry: "mx"}
m := Photo{TakenSrc: SrcMeta, PhotoName: "PhotoWithLocation", OriginalName: "demo/xyz.jpg", Place: p, PlaceID: "1000000001", PlaceSrc: SrcManual, PhotoCountry: "mx"}
assert.True(t, m.HasPlace())
assert.Equal(t, "mx", m.CountryCode())
assert.Equal(t, "Mexico", m.CountryName())
@ -72,7 +72,7 @@ func TestPhoto_EstimatePlace(t *testing.T) {
assert.Equal(t, "Mexico", m.CountryName())
})
t.Run("RecentlyEstimates", func(t *testing.T) {
m2 := Photo{PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", EstimatedAt: TimePointer(), TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC)}
m2 := Photo{TakenSrc: SrcMeta, PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", EstimatedAt: TimePointer(), TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC)}
assert.Equal(t, UnknownID, m2.CountryCode())
m2.EstimatePlace(false)
assert.Equal(t, "zz", m2.CountryCode())
@ -80,7 +80,7 @@ func TestPhoto_EstimatePlace(t *testing.T) {
assert.Equal(t, SrcAuto, m2.PlaceSrc)
})
t.Run("ForceEstimate", func(t *testing.T) {
m2 := Photo{PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", EstimatedAt: TimePointer(), TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC)}
m2 := Photo{TakenSrc: SrcMeta, PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", EstimatedAt: TimePointer(), TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC)}
assert.Equal(t, UnknownID, m2.CountryCode())
m2.EstimatePlace(true)
assert.Equal(t, "mx", m2.CountryCode())
@ -88,15 +88,24 @@ func TestPhoto_EstimatePlace(t *testing.T) {
assert.Equal(t, SrcEstimate, m2.PlaceSrc)
})
t.Run("recent photo has place", func(t *testing.T) {
m2 := Photo{PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC)}
m2 := Photo{TakenSrc: SrcMeta, PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC)}
assert.Equal(t, UnknownID, m2.CountryCode())
m2.EstimatePlace(false)
assert.Equal(t, "mx", m2.CountryCode())
assert.Equal(t, "Mexico", m2.CountryName())
assert.Equal(t, SrcEstimate, m2.PlaceSrc)
})
t.Run("SrcAuto", func(t *testing.T) {
m2 := Photo{TakenSrc: SrcAuto, PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC)}
assert.Equal(t, UnknownID, m2.CountryCode())
m2.EstimatePlace(false)
assert.Equal(t, "zz", m2.CountryCode())
assert.Equal(t, "Unknown", m2.CountryName())
assert.Equal(t, "zz", m2.PlaceID)
assert.Equal(t, SrcEstimate, m2.PlaceSrc)
})
t.Run("cant estimate - out of scope", func(t *testing.T) {
m2 := Photo{PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", TakenAt: time.Date(2016, 11, 13, 8, 7, 18, 0, time.UTC)}
m2 := Photo{TakenSrc: SrcMeta, PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", TakenAt: time.Date(2016, 11, 13, 8, 7, 18, 0, time.UTC)}
assert.Equal(t, UnknownID, m2.CountryCode())
m2.EstimatePlace(true)
assert.Equal(t, UnknownID, m2.CountryCode())

View file

@ -140,7 +140,7 @@ func TestUpdateLocation(t *testing.T) {
PhotoCountry: UnknownID,
PhotoLat: 0.0,
PhotoLng: 0.0,
PlaceID: "s2:85d1ea7d3278",
PlaceID: "mx:VvfNBpFegSCr",
PlaceSrc: SrcEstimate,
}
@ -152,7 +152,7 @@ func TestUpdateLocation(t *testing.T) {
assert.Equal(t, "mx", m.PhotoCountry)
assert.Equal(t, float32(0.0), m.PhotoLat)
assert.Equal(t, float32(0.0), m.PhotoLng)
assert.Equal(t, "s2:85d1ea7d3278", m.PlaceID)
assert.Equal(t, "mx:VvfNBpFegSCr", m.PlaceID)
assert.Equal(t, SrcEstimate, m.PlaceSrc)
})
@ -162,7 +162,7 @@ func TestUpdateLocation(t *testing.T) {
PhotoCountry: "de",
PhotoLat: 0.0,
PhotoLng: 0.0,
PlaceID: "s2:85d1ea7d3278",
PlaceID: "de:HFqPHxa2Hsol",
PlaceSrc: SrcManual,
}

View file

@ -11,10 +11,10 @@ import (
var placeMutex = sync.Mutex{}
// Place used to associate photos to places
// Place represents a distinct region identified by city, district, state, and country.
type Place struct {
ID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"PlaceID" yaml:"PlaceID"`
PlaceLabel string `gorm:"type:VARCHAR(400);unique_index;" json:"Label" yaml:"Label"`
PlaceLabel string `gorm:"type:VARCHAR(400);" json:"Label" yaml:"Label"`
PlaceDistrict string `gorm:"type:VARCHAR(100);index;" json:"District" yaml:"District,omitempty"`
PlaceCity string `gorm:"type:VARCHAR(100);index;" json:"City" yaml:"City,omitempty"`
PlaceState string `gorm:"type:VARCHAR(100);index;" json:"State" yaml:"State,omitempty"`
@ -50,19 +50,11 @@ func CreateUnknownPlace() {
}
// FindPlace finds a matching place or returns nil.
func FindPlace(id string, label string) *Place {
func FindPlace(id string) *Place {
place := Place{}
if label == "" {
if err := Db().Where("id = ?", id).First(&place).Error; err != nil {
log.Debugf("place: %s no found", txt.Quote(id))
return nil
} else {
return &place
}
}
if err := Db().Where("id = ? OR place_label = ?", id, label).First(&place).Error; err != nil {
if err := Db().Where("id = ?", id).First(&place).Error; err != nil {
log.Debugf("place: %s no found", txt.Quote(id))
return nil
} else {
return &place
@ -113,11 +105,11 @@ func FirstOrCreatePlace(m *Place) *Place {
result := Place{}
if findErr := Db().Where("id = ? OR place_label = ?", m.ID, m.PlaceLabel).First(&result).Error; findErr == nil {
if findErr := Db().Where("id = ?", m.ID).First(&result).Error; findErr == nil {
return &result
} else if createErr := m.Create(); createErr == nil {
return m
} else if err := Db().Where("id = ? OR place_label = ?", m.ID, m.PlaceLabel).First(&result).Error; err == nil {
} else if err := Db().Where("id = ?", m.ID).First(&result).Error; err == nil {
return &result
} else {
log.Errorf("place: %s (create %s)", createErr, m.ID)

View file

@ -24,8 +24,8 @@ func (m PlacesMap) Pointer(name string) *Place {
var PlaceFixtures = PlacesMap{
"mexico": {
ID: s2.TokenPrefix + "85d1ea7d3278",
PlaceLabel: "Teotihuacán, Mexico, Mexico",
ID: "mx:VvfNBpFegSCr",
PlaceLabel: "Teotihuacán, State of Mexico, Mexico",
PlaceCity: "Teotihuacán",
PlaceState: "State of Mexico",
PlaceCountry: "mx",
@ -36,7 +36,7 @@ var PlaceFixtures = PlacesMap{
UpdatedAt: TimeStamp(),
},
"zinkwazi": {
ID: s2.TokenPrefix + "1ef744d1e279",
ID: "za:Rc1K7dTWRzBD",
PlaceLabel: "KwaDukuza, KwaZulu-Natal, South Africa",
PlaceCity: "KwaDukuza",
PlaceState: "KwaZulu-Natal",
@ -48,9 +48,10 @@ var PlaceFixtures = PlacesMap{
UpdatedAt: TimeStamp(),
},
"holidaypark": {
ID: s2.TokenPrefix + "1ef744d1e280",
PlaceLabel: "Holiday Park, Amusement",
PlaceCity: "",
ID: "de:HFqPHxa2Hsol",
PlaceLabel: "Neustadt an der Weinstraße, Rheinland-Pfalz, Germany",
PlaceDistrict: "Hambach an der Weinstraße",
PlaceCity: "Neustadt an der Weinstraße",
PlaceState: "Rheinland-Pfalz",
PlaceCountry: "de",
PlaceKeywords: "",

View file

@ -12,9 +12,9 @@ func TestCreateUnknownPlace(t *testing.T) {
assert.True(t, r.Unknown())
}
func TestFindPlaceByLabel(t *testing.T) {
t.Run("find by id", func(t *testing.T) {
r := FindPlace(s2.TokenPrefix+"1ef744d1e280", "")
func TestFindPlace(t *testing.T) {
t.Run("Holiday Park", func(t *testing.T) {
r := FindPlace("de:HFqPHxa2Hsol")
if r == nil {
t.Fatal("result should not be nil")
@ -22,16 +22,16 @@ func TestFindPlaceByLabel(t *testing.T) {
assert.Equal(t, "de", r.PlaceCountry)
})
t.Run("find by id", func(t *testing.T) {
r := FindPlace(s2.TokenPrefix+"85d1ea7d3278", "")
t.Run("Mexico", func(t *testing.T) {
r := FindPlace("mx:VvfNBpFegSCr")
if r == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, "mx", r.PlaceCountry)
})
t.Run("find by label", func(t *testing.T) {
r := FindPlace("", "KwaDukuza, KwaZulu-Natal, South Africa")
t.Run("KwaDukuza", func(t *testing.T) {
r := FindPlace("za:Rc1K7dTWRzBD")
if r == nil {
t.Fatal("result should not be nil")
@ -40,14 +40,14 @@ func TestFindPlaceByLabel(t *testing.T) {
assert.Equal(t, "za", r.PlaceCountry)
})
t.Run("not matching", func(t *testing.T) {
r := FindPlace("111", "xxx")
r := FindPlace("111")
if r != nil {
t.Fatal("result should be nil")
}
})
t.Run("not matching empty label", func(t *testing.T) {
r := FindPlace("111", "")
r := FindPlace("111")
if r != nil {
t.Fatal("result should be nil")

View file

@ -78,11 +78,7 @@ func (l *Location) Unknown() bool {
}
func (l Location) PlaceID() string {
if l.placeID != "" {
return s2.Prefix(l.placeID)
}
return l.PrefixedToken()
return l.placeID
}
func (l Location) S2Token() string {

View file

@ -68,12 +68,12 @@ func TestLocation_QueryPlaces(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "", l.LocName)
assert.Equal(t, "ocean", l.LocCategory)
assert.Equal(t, "", l.LocState)
assert.Equal(t, "South Pacific Ocean", l.LocDistrict)
assert.Equal(t, "Puerto Velasco Ibarra", l.LocName)
assert.Equal(t, "", l.LocCategory)
assert.Equal(t, "Galápagos", l.LocState)
assert.Equal(t, "", l.LocDistrict)
assert.Equal(t, "ec", l.LocCountry)
assert.Equal(t, "South Pacific Ocean, Ecuador", l.LocLabel)
assert.Equal(t, "Galápagos, Ecuador", l.LocLabel)
assert.Equal(t, "places", l.LocSource)
})
}

View file

@ -179,6 +179,10 @@ func (w *Moments) Start() (err error) {
}
if a := entity.FindAlbumBySlug(mom.Slug(), entity.AlbumState); a != nil {
if err := a.UpdateState(mom.State, mom.Country); err != nil {
log.Errorf("moments: %s (update state)", err.Error())
}
if !a.Deleted() {
log.Tracef("moments: %s already exists (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
} else if err := a.Restore(); err != nil {

View file

@ -6,28 +6,24 @@ import (
"github.com/photoprism/photoprism/internal/config"
)
func TestPlaces_Start(t *testing.T) {
func TestPlaces(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
w := NewPlaces(config.TestConfig())
updated, err := w.Start()
t.Run("Start", func(t *testing.T) {
updated, err := w.Start()
if err != nil {
t.Fatal(err)
}
if err != nil {
t.Fatal(err)
}
t.Logf("updated: %#v", updated)
}
t.Logf("updated: %#v", updated)
})
func TestPlaces_UpdatePhotos(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
t.Run("success", func(t *testing.T) {
t.Run("UpdatePhotos", func(t *testing.T) {
w := NewPlaces(config.TestConfig())
affected, err := w.UpdatePhotos()

View file

@ -76,7 +76,7 @@ func Albums(f form.AlbumSearch) (results AlbumResults, err error) {
case entity.SortOrderMoment:
s = s.Order("albums.album_favorite DESC, has_year, albums.album_year DESC, albums.album_month DESC, albums.album_title ASC, albums.album_uid DESC")
case entity.SortOrderPlace:
s = s.Order("albums.album_favorite DESC, albums.album_country, albums.album_state, albums.album_title, albums.album_year DESC, albums.album_month ASC, albums.album_day ASC, albums.album_uid DESC")
s = s.Order("albums.album_favorite DESC, albums.album_location, albums.album_title, albums.album_year DESC, albums.album_month ASC, albums.album_day ASC, albums.album_uid DESC")
case entity.SortOrderName:
s = s.Order("albums.album_title ASC, albums.album_uid DESC")
case entity.SortOrderPath: