Albums: Improve state geodata and sort by country #1608 #1664

This commit is contained in:
Michael Mayer 2021-11-18 00:46:34 +01:00
parent ccb27454a6
commit c48310f077
31 changed files with 688 additions and 257 deletions

View file

@ -119,7 +119,7 @@ export default [
path: "/moments",
component: Albums,
meta: { title: $gettext("Moments"), auth: true },
props: { view: "moment", staticFilter: { type: "moment" } },
props: { view: "moment", staticFilter: { type: "moment", order: "moment" } },
},
{
name: "moment",
@ -132,7 +132,7 @@ export default [
path: "/albums",
component: Albums,
meta: { title: $gettext("Albums"), auth: true },
props: { view: "album", staticFilter: { type: "album" } },
props: { view: "album", staticFilter: { type: "album", order: "relevance" } },
},
{
name: "album",
@ -145,7 +145,7 @@ export default [
path: "/calendar",
component: Albums,
meta: { title: $gettext("Calendar"), auth: true },
props: { view: "month", staticFilter: { type: "month" } },
props: { view: "month", staticFilter: { type: "month", order: "newest" } },
},
{
name: "month",
@ -158,7 +158,7 @@ export default [
path: "/folders",
component: Albums,
meta: { title: $gettext("Folders"), auth: true },
props: { view: "folder", staticFilter: { type: "folder", order: "default" } },
props: { view: "folder", staticFilter: { type: "folder", order: "newest" } },
},
{
name: "folder",
@ -225,7 +225,7 @@ export default [
path: "/states",
component: Albums,
meta: { title: $gettext("Places"), auth: true },
props: { view: "state", staticFilter: { type: "state" } },
props: { view: "state", staticFilter: { type: "state", order: "place" } },
},
{
name: "state",

8
go.mod
View file

@ -21,7 +21,7 @@ require (
github.com/go-playground/validator/v10 v10.9.0 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/open-location-code/go v0.0.0-20211110234603-604ed00fe9d8
github.com/google/open-location-code/go v0.0.0-20211115190122-6707912175c3
github.com/gorilla/websocket v1.4.2
github.com/gosimple/slug v1.11.2
github.com/h2non/filetype v1.1.1
@ -38,7 +38,6 @@ require (
github.com/manifoldco/promptui v0.9.0
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-sqlite3 v2.0.1+incompatible // indirect
github.com/melihmucuk/geocache v0.0.0-20160621165317-521b336a001c
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/montanaflynn/stats v0.6.6
github.com/patrickmn/go-cache v2.1.0+incompatible
@ -46,7 +45,6 @@ require (
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58
github.com/satori/go.uuid v1.2.0
github.com/sevlyar/go-daemon v0.1.5
github.com/shopspring/decimal v1.2.0 // indirect
github.com/sirupsen/logrus v1.8.1
github.com/stretchr/testify v1.7.0
github.com/studio-b12/gowebdav v0.0.0-20211106090535-29e74efa701f
@ -55,9 +53,9 @@ require (
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6
github.com/urfave/cli v1.22.5
go4.org v0.0.0-20201209231011-d4a079459e60 // indirect
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
golang.org/x/net v0.0.0-20211111083644-e5c967477495
golang.org/x/net v0.0.0-20211116231205-47ca1ff31462
golang.org/x/sys v0.0.0-20211109065445-02f5c0300f6e // indirect
golang.org/x/text v0.3.7 // indirect
gonum.org/v1/gonum v0.9.3

14
go.sum
View file

@ -151,10 +151,10 @@ github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/open-location-code/go v0.0.0-20211109014933-06433367679b h1:6rfkSqY/nWZGdgpfCLumEAh3Remb/v1eyrGnFt5dCIs=
github.com/google/open-location-code/go v0.0.0-20211109014933-06433367679b/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/open-location-code/go v0.0.0-20211110234603-604ed00fe9d8 h1:+C1yt4bGEM1u3akLWEDqtNhRV28xyCrPscLEgE3NGYc=
github.com/google/open-location-code/go v0.0.0-20211110234603-604ed00fe9d8/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/open-location-code/go v0.0.0-20211115190122-6707912175c3 h1:wXfRNEdg7/vPWFXtECTJulGgxygx0xZqlB0g3JMJlXs=
github.com/google/open-location-code/go v0.0.0-20211115190122-6707912175c3/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@ -225,8 +225,6 @@ github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw=
github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/melihmucuk/geocache v0.0.0-20160621165317-521b336a001c h1:1ErTnOL2d0OvfUABvEjGcPM8cKSLxYZpJiYS4BfQ3o4=
github.com/melihmucuk/geocache v0.0.0-20160621165317-521b336a001c/go.mod h1:CX2bLGC22DrgJTaYvKt+lOi3BACGNA60hbFXh2iWebs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -265,8 +263,6 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sevlyar/go-daemon v0.1.5 h1:Zy/6jLbM8CfqJ4x4RPr7MJlSKt90f00kNM1D401C+Qk=
github.com/sevlyar/go-daemon v0.1.5/go.mod h1:6dJpPatBT9eUwM5VCw9Bt6CdX9Tk6UWvhW3MebLDRKE=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
@ -314,6 +310,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa h1:idItI2DDfCokpg0N51B2VtiLdJ4vAuXC9fnCb2gACo4=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI=
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -377,10 +375,10 @@ golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211108170745-6635138e15ea h1:FosBMXtOc8Tp9Hbo4ltl1WJSrTVewZU8MPnTPY2HdH8=
golang.org/x/net v0.0.0-20211108170745-6635138e15ea/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211111083644-e5c967477495 h1:cjxxlQm6d4kYbhpZ2ghvmI8xnq0AG+jXmzrhzfkyu5A=
golang.org/x/net v0.0.0-20211111083644-e5c967477495/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211116231205-47ca1ff31462 h1:2vmJlzGKvQ7e/X9XT0XydeWDxmqx8DnegiIMRT+5ssI=
golang.org/x/net v0.0.0-20211116231205-47ca1ff31462/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View file

@ -2,7 +2,6 @@ package api
import (
"archive/zip"
"fmt"
"net/http"
"path/filepath"
"strings"
@ -41,6 +40,8 @@ func SaveAlbumAsYaml(a entity.Album) {
}
}
// SearchAlbums finds albums and returns them as JSON.
//
// GET /api/v1/albums
func SearchAlbums(router *gin.RouterGroup) {
router.GET("/albums", func(c *gin.Context) {
@ -81,6 +82,8 @@ func SearchAlbums(router *gin.RouterGroup) {
})
}
// GetAlbum returns album details as JSON.
//
// GET /api/v1/albums/:uid
func GetAlbum(router *gin.RouterGroup) {
router.GET("/albums/:uid", func(c *gin.Context) {
@ -103,6 +106,8 @@ func GetAlbum(router *gin.RouterGroup) {
})
}
// CreateAlbum adds a new album.
//
// POST /api/v1/albums
func CreateAlbum(router *gin.RouterGroup) {
router.POST("/albums", func(c *gin.Context) {
@ -142,6 +147,8 @@ func CreateAlbum(router *gin.RouterGroup) {
})
}
// UpdateAlbum updates album metadata like title and description.
//
// PUT /api/v1/albums/:uid
func UpdateAlbum(router *gin.RouterGroup) {
router.PUT("/albums/:uid", func(c *gin.Context) {
@ -192,6 +199,8 @@ func UpdateAlbum(router *gin.RouterGroup) {
})
}
// DeleteAlbum deletes an existing album.
//
// DELETE /api/v1/albums/:uid
func DeleteAlbum(router *gin.RouterGroup) {
router.DELETE("/albums/:uid", func(c *gin.Context) {
@ -229,6 +238,8 @@ func DeleteAlbum(router *gin.RouterGroup) {
})
}
// LikeAlbum sets the favorite flag for an album.
//
// POST /api/v1/albums/:uid/like
//
// Parameters:
@ -265,6 +276,8 @@ func LikeAlbum(router *gin.RouterGroup) {
})
}
// DislikeAlbum removes the favorite flag from an album.
//
// DELETE /api/v1/albums/:uid/like
//
// Parameters:
@ -301,6 +314,8 @@ func DislikeAlbum(router *gin.RouterGroup) {
})
}
// CloneAlbums creates a new album containing pictures from other albums.
//
// POST /api/v1/albums/:uid/clone
func CloneAlbums(router *gin.RouterGroup) {
router.POST("/albums/:uid/clone", func(c *gin.Context) {
@ -357,6 +372,8 @@ func CloneAlbums(router *gin.RouterGroup) {
})
}
// AddPhotosToAlbum adds photos to an album.
//
// POST /api/v1/albums/:uid/photos
func AddPhotosToAlbum(router *gin.RouterGroup) {
router.POST("/albums/:uid/photos", func(c *gin.Context) {
@ -410,6 +427,8 @@ func AddPhotosToAlbum(router *gin.RouterGroup) {
})
}
// RemovePhotosFromAlbum removes photos from an album.
//
// DELETE /api/v1/albums/:uid/photos
func RemovePhotosFromAlbum(router *gin.RouterGroup) {
router.DELETE("/albums/:uid/photos", func(c *gin.Context) {
@ -459,6 +478,8 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup) {
})
}
// DownloadAlbum streams the album contents as zip archive.
//
// GET /api/v1/albums/:uid/dl
func DownloadAlbum(router *gin.RouterGroup) {
router.GET("/albums/:uid/dl", func(c *gin.Context) {
@ -482,13 +503,7 @@ func DownloadAlbum(router *gin.RouterGroup) {
return
}
albumName := strings.Title(a.AlbumSlug)
if len(albumName) < 2 {
albumName = fmt.Sprintf("photoprism-album-%s", a.AlbumUID)
}
zipFileName := fmt.Sprintf("%s.zip", albumName)
zipFileName := a.ZipName()
AddDownloadHeader(c, zipFileName)

View file

@ -15,7 +15,7 @@ func TestGetConfig(t *testing.T) {
GetConfig(router)
r := PerformRequest(app, "GET", "/api/v1/config")
val := gjson.Get(r.Body.String(), "flags")
assert.Equal(t, "public debug experimental settings", val.String())
assert.Equal(t, "public debug sponsor experimental settings", val.String())
assert.Equal(t, http.StatusOK, r.Code)
})
}

View file

@ -4,6 +4,8 @@ import (
"context"
"time"
"github.com/manifoldco/promptui"
"github.com/dustin/go-humanize/english"
"github.com/urfave/cli"
@ -15,11 +17,11 @@ import (
// PlacesCommand registers the places subcommands.
var PlacesCommand = cli.Command{
Name: "places",
Usage: "Location information subcommands",
Usage: "Geolocation management subcommands",
Subcommands: []cli.Command{
{
Name: "update",
Usage: "Fetches updated location data",
Usage: "Updates the index with the latest geodata from our backend",
Action: placesUpdateAction,
},
},
@ -29,6 +31,16 @@ var PlacesCommand = cli.Command{
func placesUpdateAction(ctx *cli.Context) error {
start := time.Now()
confirmPrompt := promptui.Prompt{
Label: "Interrupting the update may result in inconsistent data. Proceed?",
IsConfirm: true,
}
if _, err := confirmPrompt.Run(); err != nil {
// Abort.
return nil
}
conf := config.NewConfig(ctx)
service.SetConfig(conf)
@ -49,7 +61,7 @@ func placesUpdateAction(ctx *cli.Context) error {
} else {
elapsed := time.Since(start)
log.Infof("updated %s in %s", english.Plural(len(updated), "location", "locations"), elapsed)
log.Infof("updated %s in %s", english.Plural(len(updated), "place", "places"), elapsed)
}
conf.Shutdown()

View file

@ -133,6 +133,10 @@ func (c *Config) Flags() (flags []string) {
flags = append(flags, "debug")
}
if c.Sponsor() {
flags = append(flags, "sponsor")
}
if c.Experimental() {
flags = append(flags, "experimental")
}
@ -245,7 +249,7 @@ func (c *Config) GuestConfig() ClientConfig {
Faces: true,
Classification: true,
},
Flags: "readonly public shared",
Flags: strings.Join(c.Flags(), " "),
Mode: "guest",
Name: c.Name(),
BaseUri: c.BaseUri(""),

View file

@ -33,7 +33,7 @@ func TestConfig_Flags(t *testing.T) {
config.settings.UI.Scrollbar = false
result := config.Flags()
assert.Equal(t, []string{"public", "debug", "experimental", "readonly", "settings", "hide-scrollbar"}, result)
assert.Equal(t, []string{"public", "debug", "sponsor", "experimental", "readonly", "settings", "hide-scrollbar"}, result)
config.options.Experimental = false
config.options.ReadOnly = false

View file

@ -39,6 +39,11 @@ var once sync.Once
var LowMem = false
var TotalMem uint64
const MsgFreeBeer = "Help us make a difference and become a sponsor today!"
const MsgFundingInfo = "Visit https://docs.photoprism.org/funding/ to learn more."
const MsgSponsorCommand = "Since running this command puts additional load on our infrastructure," +
" we unfortunately can't offer it for free."
const ApiUri = "/api/v1"
const StaticUri = "/static"
const DefaultWakeupInterval = int(15 * 60)
@ -170,6 +175,12 @@ func (c *Config) Init() error {
return err
}
// Show funding info?
if !c.Sponsor() {
log.Info(MsgFreeBeer)
log.Info(MsgFundingInfo)
}
if insensitive, err := c.CaseInsensitive(); err != nil {
return err
} else if insensitive {

View file

@ -1,8 +1,9 @@
package crop
import (
"github.com/stretchr/testify/assert"
"testing"
"github.com/stretchr/testify/assert"
)
func TestThumbFileName(t *testing.T) {

View file

@ -1,8 +1,9 @@
package crop
import (
"github.com/stretchr/testify/assert"
"testing"
"github.com/stretchr/testify/assert"
)
func TestName_Jpeg(t *testing.T) {

View file

@ -448,6 +448,17 @@ func (m *Album) Title() string {
return m.AlbumTitle
}
// ZipName returns the zip download filename.
func (m *Album) ZipName() string {
s := slug.Make(m.AlbumTitle)
if len(s) < 2 {
s = fmt.Sprintf("photoprism-album-%s", m.AlbumUID)
}
return fmt.Sprintf("%s.zip", s)
}
// AddPhotos adds photos to an existing album.
func (m *Album) AddPhotos(UIDs []string) (added PhotoAlbums) {
for _, uid := range UIDs {

View file

@ -18,6 +18,8 @@ var cellMutex = sync.Mutex{}
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"`
CellStreet string `gorm:"type:VARCHAR(100);" json:"Street" yaml:"Street,omitempty"`
CellPostcode string `gorm:"type:VARCHAR(50);" json:"Postcode" yaml:"Postcode,omitempty"`
CellCategory string `gorm:"type:VARCHAR(50);" json:"Category" yaml:"Category,omitempty"`
PlaceID string `gorm:"type:VARBINARY(42);default:'zz'" json:"-" yaml:"PlaceID"`
Place *Place `gorm:"PRELOAD:true" json:"Place" yaml:"-"`
@ -36,6 +38,8 @@ var UnknownLocation = Cell{
Place: &UnknownPlace,
PlaceID: UnknownID,
CellName: "",
CellStreet: "",
CellPostcode: "",
CellCategory: "",
}
@ -55,14 +59,14 @@ func NewCell(lat, lng float32) *Cell {
// Refresh updates the index by retrieving the latest data from an external API.
func (m *Cell) Refresh(api string) (err error) {
start := time.Now()
// Unknown?
if m.Unknown() {
// Skip.
return nil
}
start := time.Now()
// Initialize.
l := &maps.Location{
ID: s2.NormalizeToken(m.ID),
@ -79,44 +83,50 @@ func (m *Cell) Refresh(api string) (err error) {
return nil
}
cellTable := Cell{}.TableName()
placeTable := Place{}.TableName()
oldPlaceID := m.PlaceID
place := Place{}
cellMutex.Lock()
defer cellMutex.Unlock()
// Find existing place by label.
if err := UnscopedDb().Where("place_label = ?", l.Label()).First(&place).Error; err != nil {
log.Tracef("places: %s for cell %s", err, m.ID)
place = Place{ID: m.ID}
} else {
log.Tracef("places: found matching place %s for cell %s", place.ID, m.ID)
place := Place{
ID: l.PlaceID(),
PlaceLabel: l.Label(),
PlaceDistrict: l.District(),
PlaceCity: l.City(),
PlaceState: l.State(),
PlaceCountry: l.CountryCode(),
PlaceKeywords: l.KeywordString(),
PhotoCount: 1,
}
// Update place.
if place.ID == "" {
// Do nothing.
} else if res := UnscopedDb().Table(placeTable).Where("id = ?", place.ID).UpdateColumns(Values{
"place_label": l.Label(),
"place_city": l.City(),
"place_district": l.District(),
"place_state": l.State(),
"place_country": l.CountryCode(),
"place_keywords": l.KeywordString(),
}); res.Error != nil {
log.Tracef("places: %s for cell %s", err, m.ID)
} else if res.RowsAffected > 0 {
// Update cell place id, name, and category.
log.Tracef("places: updating place, name, and category for cell %s", m.ID)
err = UnscopedDb().Table(cellTable).Where("id = ?", m.ID).
UpdateColumns(Values{"cell_name": l.Name(), "cell_category": l.Category(), "place_id": place.ID}).Error
// Create or update place.
if err = place.Save(); err != nil {
log.Warnf("place: failed updating %s [%s]", place.ID, time.Since(start))
} else {
// Update cell name and category.
log.Tracef("places: updating name and category for cell %s", m.ID)
err = UnscopedDb().Table(cellTable).Where("id = ?", m.ID).
UpdateColumns(Values{"cell_name": l.Name(), "cell_category": l.Category()}).Error
m.Place = &place
m.PlaceID = l.PlaceID()
log.Tracef("place: updated %s [%s]", place.ID, time.Since(start))
}
log.Debugf("places: refreshed cell %s [%s]", txt.Quote(m.ID), time.Since(start))
m.CellName = l.Name()
m.CellStreet = l.Street()
m.CellPostcode = l.Postcode()
m.CellCategory = l.Category()
// Update cell.
err = m.Save()
if err != nil {
log.Warnf("place: failed updating %s [%s]", m.ID, time.Since(start))
return err
} else if oldPlaceID != m.PlaceID {
err = UnscopedDb().Table(Photo{}.TableName()).
Where("place_id = ?", oldPlaceID).
UpdateColumn("place_id", m.PlaceID).
Error
}
log.Debugf("place: updated %s [%s]", m.ID, time.Since(start))
return err
}
@ -127,7 +137,7 @@ func (m *Cell) Find(api string) error {
db := Db()
if err := db.Preload("Place").First(m, "id = ?", m.ID).Error; err == nil {
log.Debugf("location: found cell %s", m.ID)
log.Debugf("place: found cell %s", m.ID)
return nil
}
@ -139,12 +149,13 @@ func (m *Cell) Find(api string) error {
return err
}
if found := FindPlace(l.PrefixedToken(), l.Label()); found != nil {
if found := FindPlace(l.PlaceID(), l.Label()); found != nil {
m.Place = found
} else {
place := &Place{
ID: l.PrefixedToken(),
ID: l.PlaceID(),
PlaceLabel: l.Label(),
PlaceDistrict: l.District(),
PlaceCity: l.City(),
PlaceState: l.State(),
PlaceCountry: l.CountryCode(),
@ -157,33 +168,35 @@ func (m *Cell) Find(api string) error {
"count": 1,
})
log.Infof("location: added place %s [%s]", place.ID, time.Since(start))
log.Infof("place: added %s [%s]", place.ID, time.Since(start))
m.Place = place
} else if found := FindPlace(l.PrefixedToken(), l.Label()); found != nil {
} else if found := FindPlace(l.PlaceID(), l.Label()); found != nil {
m.Place = found
} else {
log.Errorf("location: %s (create place %s)", createErr, place.ID)
log.Errorf("place: %s (create %s)", createErr, place.ID)
m.Place = &UnknownPlace
}
}
m.PlaceID = m.Place.ID
m.CellName = l.Name()
m.CellStreet = l.Street()
m.CellPostcode = l.Postcode()
m.CellCategory = l.Category()
cellMutex.Lock()
defer cellMutex.Unlock()
if createErr := db.Create(m).Error; createErr == nil {
log.Debugf("location: added cell %s [%s]", m.ID, time.Since(start))
log.Debugf("place: added cell %s [%s]", m.ID, time.Since(start))
return nil
} else if findErr := db.Preload("Place").First(m, "id = ?", m.ID).Error; findErr != nil {
log.Errorf("location: %s (create cell %s)", createErr, m.ID)
log.Errorf("location: %s (find cell %s)", findErr, m.ID)
log.Errorf("place: %s (create cell %s)", createErr, m.ID)
log.Errorf("place: %s (find cell %s)", findErr, m.ID)
return createErr
} else {
log.Debugf("location: found cell %s [%s]", m.ID, time.Since(start))
log.Debugf("place: found cell %s [%s]", m.ID, time.Since(start))
}
return nil
@ -194,15 +207,25 @@ func (m *Cell) Create() error {
return Db().Create(m).Error
}
// Save updates the existing or inserts a new row.
func (m *Cell) Save() error {
return Db().Save(m).Error
}
// Delete removes the entity from the index.
func (m *Cell) Delete() (err error) {
return UnscopedDb().Delete(m).Error
}
// FirstOrCreateCell fetches an existing row, inserts a new row or nil in case of errors.
func FirstOrCreateCell(m *Cell) *Cell {
if m.ID == "" {
log.Errorf("location: cell must not be empty")
log.Errorf("place: cell must not be empty")
return nil
}
if m.PlaceID == "" {
log.Errorf("location: place must not be empty (find or create cell %s)", m.ID)
log.Errorf("place: id must not be empty (find or create cell %s)", m.ID)
return nil
}
@ -215,7 +238,7 @@ func FirstOrCreateCell(m *Cell) *Cell {
} else if err := Db().Where("id = ?", m.ID).Preload("Place").First(&result).Error; err == nil {
return &result
} else {
log.Errorf("location: %s (find or create cell %s)", createErr, m.ID)
log.Errorf("place: %s (find or create cell %s)", createErr, m.ID)
}
return nil
@ -224,15 +247,17 @@ func FirstOrCreateCell(m *Cell) *Cell {
// Keywords returns search keywords for a location.
func (m *Cell) Keywords() (result []string) {
if m.Place == nil {
log.Errorf("location: place for cell %s is nil - you might have found a bug", m.ID)
log.Errorf("place: info for cell %s is nil - you might have found a bug", m.ID)
return result
}
result = append(result, txt.Keywords(txt.ReplaceSpaces(m.District(), "-"))...)
result = append(result, txt.Keywords(txt.ReplaceSpaces(m.City(), "-"))...)
result = append(result, txt.Keywords(txt.ReplaceSpaces(m.State(), "-"))...)
result = append(result, txt.Keywords(txt.ReplaceSpaces(m.CountryName(), "-"))...)
result = append(result, txt.Keywords(m.Category())...)
result = append(result, txt.Keywords(m.Name())...)
result = append(result, txt.Keywords(m.Street())...)
result = append(result, txt.Keywords(m.Category())...)
result = append(result, txt.Words(m.Place.PlaceKeywords)...)
result = txt.UniqueWords(result)
@ -255,6 +280,26 @@ func (m *Cell) NoName() bool {
return m.CellName == ""
}
// Street returns the street name if any.
func (m *Cell) Street() string {
return m.CellStreet
}
// NoStreet checks if the location has a street.
func (m *Cell) NoStreet() bool {
return m.CellStreet == ""
}
// Postcode returns the postcode if any.
func (m *Cell) Postcode() string {
return m.CellPostcode
}
// NoPostcode checks if the location has a postcode.
func (m *Cell) NoPostcode() bool {
return m.CellPostcode == ""
}
// Category returns the location category
func (m *Cell) Category() string {
return m.CellCategory
@ -270,7 +315,12 @@ func (m *Cell) Label() string {
return m.Place.Label()
}
// City returns the location place city
// District returns the district name if any.
func (m *Cell) District() string {
return m.Place.District()
}
// City returns the location city name if any.
func (m *Cell) City() string {
return m.Place.City()
}

View file

@ -64,11 +64,17 @@ const (
// Sort Orders
const (
SortOrderRelevance = "relevance"
SortOrderCount = "count"
SortOrderAdded = "added"
SortOrderEdited = "edited"
SortOrderNewest = "newest"
SortOrderOldest = "oldest"
SortOrderPlace = "place"
SortOrderMoment = "moment"
SortOrderName = "name"
SortOrderPath = "path"
SortOrderSlug = "slug"
SortOrderCategory = "category"
SortOrderSimilar = "similar"
SortOrderRelevance = "relevance"
SortOrderEdited = "edited"
)

View file

@ -22,6 +22,43 @@ type DbProvider interface {
Db() *gorm.DB
}
// RecreateTable drops and recreates the database table for a clean start.
func RecreateTable(models ...interface{}) (err error) {
n := len(models)
// Return if no models were provided.
if n < 1 {
return nil
}
// Drop existing tables.
if err = Db().DropTable(models...).Error; err != nil {
return fmt.Errorf("%s (drop table)", err)
}
done := 0
// Create dropped tables.
for i := 0; i < 15; i++ {
for m := range models {
if err = Db().CreateTable(models[m]).Error; err != nil {
log.Debugf("entity: %s (create table)", err)
} else {
done++
}
if done >= n {
return err
}
}
// Wait a second to avoid timing issues.
time.Sleep(time.Second)
}
return err
}
// IsDialect returns true if the given sql dialect is used.
func IsDialect(name string) bool {
return name == Db().Dialect().GetName()

View file

@ -0,0 +1,32 @@
package entity
import (
"testing"
"time"
)
// TestEntity is an entity dedicated to test database management functionality.
type TestEntity struct {
ID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"TestID" yaml:"TestID"`
TestLabel string `gorm:"type:VARCHAR(400);unique_index;" json:"Label" yaml:"Label"`
TestCount int `gorm:"default:1" json:"Count" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
}
// TableName returns the entity database table name.
func (TestEntity) TableName() string {
return "test_ignore"
}
func TestRecreateTable(t *testing.T) {
t.Run("TestEntity", func(t *testing.T) {
if err := Db().CreateTable(TestEntity{}).Error; err != nil {
t.Fatal(err)
}
if err := RecreateTable(TestEntity{}); err != nil {
t.Fatal(err)
}
})
}

View file

@ -6,6 +6,7 @@ import (
"time"
"github.com/photoprism/photoprism/internal/maps"
"github.com/photoprism/photoprism/pkg/txt"
)
var placeMutex = sync.Mutex{}
@ -13,12 +14,12 @@ var placeMutex = sync.Mutex{}
// Place used to associate photos to places
type Place struct {
ID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"PlaceID" yaml:"PlaceID"`
PlaceLabel string `gorm:"type:VARBINARY(512);unique_index;" json:"Label" yaml:"Label"`
PlaceCity string `gorm:"type:VARCHAR(128);" json:"City" yaml:"City,omitempty"`
PlaceState string `gorm:"type:VARCHAR(128);" json:"State" yaml:"State,omitempty"`
PlaceDistrict string `gorm:"type:VARCHAR(128);" json:"District" yaml:"District,omitempty"`
PlaceLabel string `gorm:"type:VARCHAR(400);unique_index;" 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"`
PlaceCountry string `gorm:"type:VARBINARY(2);" json:"Country" yaml:"Country,omitempty"`
PlaceKeywords string `gorm:"type:VARCHAR(255);" json:"Keywords" yaml:"Keywords,omitempty"`
PlaceKeywords string `gorm:"type:VARCHAR(300);" json:"Keywords" yaml:"Keywords,omitempty"`
PlaceFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"`
PhotoCount int `gorm:"default:1" json:"PhotoCount" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
@ -34,8 +35,8 @@ func (Place) TableName() string {
var UnknownPlace = Place{
ID: UnknownID,
PlaceLabel: "Unknown",
PlaceCity: "Unknown",
PlaceDistrict: "Unknown",
PlaceCity: "Unknown",
PlaceState: "Unknown",
PlaceCountry: UnknownID,
PlaceKeywords: "",
@ -54,7 +55,7 @@ func FindPlace(id string, label string) *Place {
if label == "" {
if err := Db().Where("id = ?", id).First(&place).Error; err != nil {
log.Debugf("places: failed finding %s", id)
log.Debugf("place: %s no found", txt.Quote(id))
return nil
} else {
return &place
@ -85,15 +86,28 @@ func (m *Place) Create() error {
return Db().Create(m).Error
}
// Save updates the existing or inserts a new row.
func (m *Place) Save() error {
placeMutex.Lock()
defer placeMutex.Unlock()
return Db().Save(m).Error
}
// Delete removes the entity from the index.
func (m *Place) Delete() (err error) {
return UnscopedDb().Delete(m).Error
}
// FirstOrCreatePlace fetches an existing row, inserts a new row or nil in case of errors.
func FirstOrCreatePlace(m *Place) *Place {
if m.ID == "" {
log.Errorf("places: place must not be empty (find or create)")
log.Errorf("place: id must not be empty (find or create)")
return nil
}
if m.PlaceLabel == "" {
log.Errorf("places: label must not be empty (find or create place %s)", m.ID)
log.Errorf("place: label must not be empty (find or create place %s)", m.ID)
return nil
}
@ -106,7 +120,7 @@ func FirstOrCreatePlace(m *Place) *Place {
} else if err := Db().Where("id = ? OR place_label = ?", m.ID, m.PlaceLabel).First(&result).Error; err == nil {
return &result
} else {
log.Errorf("places: %s (create place %s)", createErr, m.ID)
log.Errorf("place: %s (create %s)", createErr, m.ID)
}
return nil
@ -122,7 +136,12 @@ func (m Place) Label() string {
return m.PlaceLabel
}
// City returns place City
// District returns the place district name if any.
func (m Place) District() string {
return m.PlaceDistrict
}
// City returns place city if any.
func (m Place) City() string {
return m.PlaceCity
}

View file

@ -18,6 +18,8 @@ type Location struct {
LocLat float64 `json:"lat"`
LocLng float64 `json:"lng"`
LocName string `json:"name"`
LocStreet string `json:"street"`
LocPostcode string `json:"postcode"`
LocCategory string `json:"category"`
Place Place `json:"place"`
Cached bool `json:"-"`
@ -31,20 +33,6 @@ var UserAgent = "PhotoPrism/0.0.0"
var ReverseLookupURL = "https://places.photoprism.app/v1/location/%s"
var client = &http.Client{Timeout: 60 * time.Second}
func NewLocation(id string, lat, lng float64, name, category string, place Place, cached bool) *Location {
result := &Location{
ID: id,
LocLat: lat,
LocLng: lng,
LocName: name,
LocCategory: category,
Place: place,
Cached: cached,
}
return result
}
func FindLocation(id string) (result Location, err error) {
if len(id) > 16 || len(id) == 0 {
return result, fmt.Errorf("invalid cell %s (%s)", id, ApiName)
@ -119,14 +107,26 @@ func FindLocation(id string) (result Location, err error) {
return result, nil
}
func (l Location) CellID() (result string) {
func (l Location) CellID() string {
return l.ID
}
func (l Location) PlaceID() string {
return l.Place.PlaceID
}
func (l Location) Name() (result string) {
return strings.SplitN(l.LocName, "/", 2)[0]
}
func (l Location) Street() (result string) {
return strings.SplitN(l.LocStreet, "/", 2)[0]
}
func (l Location) Postcode() (result string) {
return strings.SplitN(l.LocPostcode, "/", 2)[0]
}
func (l Location) Category() (result string) {
return l.LocCategory
}

View file

@ -40,8 +40,18 @@ func TestFindLocation(t *testing.T) {
t.Log(l)
})
t.Run("cached true", func(t *testing.T) {
var p = NewPlace("1", "", "", "", "", "de", "")
location := NewLocation("1e95998417cc", 52.51961810676184, 13.40806264572578, "TestLocation", "test", p, true)
location := Location{
ID: "1e95998417cc",
LocLat: 52.51961810676184,
LocLng: 13.40806264572578,
LocName: "TestLocation",
LocStreet: "",
LocPostcode: "",
LocCategory: "test",
Place: Place{PlaceID: "1"},
Cached: true,
}
l, err := FindLocation(location.ID)
if err != nil {
t.Fatal(err)
@ -57,8 +67,17 @@ func TestFindLocation(t *testing.T) {
}
func TestLocationGetters(t *testing.T) {
var p = NewPlace("1", "testLabel", "Berlin", "", "Berlin", "de", "foobar")
location := NewLocation("1e95998417cc", 52.51961810676184, 13.40806264572578, "TestLocation", "test", p, true)
location := Location{
ID: "1e95998417cc",
LocLat: 52.51961810676184,
LocLng: 13.40806264572578,
LocName: "TestLocation",
LocStreet: "",
LocPostcode: "",
LocCategory: "test",
Place: Place{PlaceID: "1", LocLabel: "testLabel", LocDistrict: "Berlin", LocCity: "", LocState: "Berlin", LocCountry: "de", LocKeywords: "foobar"},
Cached: true,
}
t.Run("wrong id", func(t *testing.T) {
assert.Equal(t, "1e95998417cc", location.CellID())
assert.Equal(t, "TestLocation", location.Name())
@ -66,7 +85,8 @@ func TestLocationGetters(t *testing.T) {
assert.Equal(t, "testLabel", location.Label())
assert.Equal(t, "Berlin", location.State())
assert.Equal(t, "de", location.CountryCode())
assert.Equal(t, "Berlin", location.City())
assert.Equal(t, "Berlin", location.District())
assert.Equal(t, "", location.City())
assert.Equal(t, 52.51961810676184, location.Latitude())
assert.Equal(t, 13.40806264572578, location.Longitude())
assert.Equal(t, "places", location.Source())
@ -75,9 +95,18 @@ func TestLocationGetters(t *testing.T) {
}
func TestLocation_State(t *testing.T) {
location := Location{
ID: "54903ee07f74",
LocLat: 47.6129432,
LocLng: -122.4821475,
LocName: "TestLocation",
LocStreet: "",
LocPostcode: "",
LocCategory: "test",
Place: Place{PlaceID: "549ed22c0434", LocLabel: "Seattle, WA", LocDistrict: "Berlin", LocCity: "Seattle", LocState: "WA", LocCountry: "us", LocKeywords: "foobar"},
Cached: true,
}
t.Run("Washington", func(t *testing.T) {
var p = NewPlace("549ed22c0434", "Seattle, WA", "Seattle", "", "WA", "us", "")
location := NewLocation("54903ee07f74", 47.6129432, -122.4821475, "", "", p, true)
assert.Equal(t, "54903ee07f74", location.CellID())
assert.Equal(t, "Seattle, WA", location.Label())
assert.Equal(t, "Washington", location.State())
@ -88,9 +117,18 @@ func TestLocation_State(t *testing.T) {
}
func TestLocation_District(t *testing.T) {
location := Location{
ID: "54903ee07f74",
LocLat: 47.6129432,
LocLng: -122.4821475,
LocName: "TestLocation",
LocStreet: "",
LocPostcode: "",
LocCategory: "test",
Place: Place{PlaceID: "549ed22c0434", LocLabel: "Seattle, WA", LocDistrict: "Foo", LocCity: "Seattle", LocState: "WA", LocCountry: "us", LocKeywords: "foobar"},
Cached: true,
}
t.Run("Washington", func(t *testing.T) {
var p = NewPlace("549ed22c0434", "Seattle, WA", "Seattle", "Foo", "WA", "us", "")
location := NewLocation("54903ee07f74", 47.6129432, -122.4821475, "", "", p, true)
assert.Equal(t, "54903ee07f74", location.CellID())
assert.Equal(t, "Seattle, WA", location.Label())
assert.Equal(t, "Foo", location.District())

View file

@ -4,23 +4,9 @@ package places
type Place struct {
PlaceID string `json:"id"`
LocLabel string `json:"label"`
LocCity string `json:"city"`
LocDistrict string `json:"district"`
LocCity string `json:"city"`
LocState string `json:"state"`
LocCountry string `json:"country"`
LocKeywords string `json:"keywords"`
}
func NewPlace(id, label, city, district, state, country, keywords string) Place {
result := Place{
PlaceID: id,
LocLabel: label,
LocCity: city,
LocDistrict: district,
LocState: state,
LocCountry: country,
LocKeywords: keywords,
}
return result
}

View file

@ -5,7 +5,6 @@ import (
"strings"
"github.com/photoprism/photoprism/internal/hub/places"
"github.com/photoprism/photoprism/pkg/s2"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -13,44 +12,33 @@ import (
// Location represents a geolocation.
type Location struct {
ID string
placeID string
LocName string
LocStreet string
LocPostcode string
LocCategory string
LocLabel string
LocCity string
LocDistrict string
LocCity string
LocState string
LocCountry string
LocSource string
LocKeywords []string
LocSource string
}
type LocationSource interface {
CellID() string
CountryCode() string
Category() string
PlaceID() string
Name() string
City() string
Street() string
Category() string
Postcode() string
District() string
City() string
State() string
Source() string
CountryCode() string
Keywords() []string
}
func NewLocation(id, name, category, label, city, district, state, country, source string, keywords []string) *Location {
result := &Location{
ID: id,
LocName: name,
LocCategory: category,
LocLabel: label,
LocCity: city,
LocDistrict: district,
LocCountry: country,
LocState: txt.NormalizeState(state, country),
LocSource: source,
LocKeywords: keywords,
}
return result
Source() string
}
func (l *Location) QueryApi(api string) error {
@ -69,14 +57,17 @@ func (l *Location) QueryPlaces() error {
return err
}
l.placeID = s.PlaceID()
l.LocSource = s.Source()
l.LocName = s.Name()
l.LocCity = s.City()
l.LocDistrict = s.District()
l.LocState = s.State()
l.LocCountry = s.CountryCode()
l.LocStreet = s.Street()
l.LocPostcode = s.Postcode()
l.LocCategory = s.Category()
l.LocLabel = s.Label()
l.LocDistrict = s.District()
l.LocCity = s.City()
l.LocState = s.State()
l.LocCountry = s.CountryCode()
l.LocKeywords = s.Keywords()
return nil
@ -86,6 +77,14 @@ func (l *Location) Unknown() bool {
return l.ID == ""
}
func (l Location) PlaceID() string {
if l.placeID != "" {
return s2.Prefix(l.placeID)
}
return l.PrefixedToken()
}
func (l Location) S2Token() string {
return l.ID
}
@ -95,45 +94,53 @@ func (l Location) PrefixedToken() string {
}
func (l Location) Name() string {
return txt.Shorten(l.LocName, txt.ClipTitle, txt.Ellipsis)
return txt.Clip(l.LocName, 200)
}
func (l Location) Street() string {
return txt.Clip(l.LocStreet, 100)
}
func (l Location) Postcode() string {
return txt.Clip(l.LocPostcode, 50)
}
func (l Location) Category() string {
return txt.Shorten(l.LocCategory, txt.ClipKeyword, txt.Ellipsis)
return txt.Clip(l.LocCategory, 50)
}
func (l Location) Label() string {
return txt.Shorten(l.LocLabel, txt.ClipLabel, txt.Ellipsis)
return txt.Clip(l.LocLabel, 400)
}
func (l Location) City() string {
return txt.Shorten(l.LocCity, txt.ClipPlace, txt.Ellipsis)
return txt.Clip(l.LocCity, 100)
}
func (l Location) District() string {
return txt.Shorten(l.LocDistrict, txt.ClipPlace, txt.Ellipsis)
return txt.Clip(l.LocDistrict, 100)
}
func (l Location) CountryCode() string {
return txt.Clip(l.LocCountry, txt.ClipCountryCode)
return txt.Clip(l.LocCountry, 2)
}
func (l Location) State() string {
return txt.Shorten(txt.NormalizeState(l.LocState, l.CountryCode()), txt.ClipPlace, txt.Ellipsis)
return txt.Clip(txt.NormalizeState(l.LocState, l.CountryCode()), 100)
}
func (l Location) CountryName() string {
return CountryNames[l.LocCountry]
}
func (l Location) Source() string {
return l.LocSource
}
func (l Location) Keywords() []string {
return l.LocKeywords
}
func (l Location) KeywordString() string {
return txt.Clip(strings.Join(l.LocKeywords, ", "), txt.ClipVarchar)
return txt.Clip(strings.Join(l.LocKeywords, ", "), 300)
}
func (l Location) Source() string {
return l.LocSource
}

View file

@ -3,24 +3,79 @@ package maps
import (
"testing"
"github.com/photoprism/photoprism/pkg/s2"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/s2"
)
func TestLocation_QueryPlaces(t *testing.T) {
t.Run("U Berliner Rathaus", func(t *testing.T) {
t.Run("BerlinerRathaus", func(t *testing.T) {
lat := 52.51961810676184
lng := 13.40806264572578
id := s2.Token(lat, lng)
l := NewLocation(id, "", "", "", "", "", "", "", "", []string{})
l := Location{ID: id}
if err := l.QueryPlaces(); err != nil {
t.Fatal(err)
}
t.Logf("BerlinerRathaus: %#v", l)
assert.Equal(t, "Mitte, Berlin, Germany", l.LocLabel)
})
t.Run("BerlinerFernsehturm", func(t *testing.T) {
lat := 52.5208
lng := 13.40953
id := s2.Token(lat, lng)
l := Location{ID: id}
if err := l.QueryPlaces(); err != nil {
t.Fatal(err)
}
t.Logf("BerlinerFernsehturm: %#v", l)
assert.Equal(t, "Berliner Fernsehturm", l.LocName)
assert.Equal(t, "Berlin", l.LocState)
assert.Equal(t, "Berlin", l.LocCity)
assert.Equal(t, "Panoramastraße", l.LocStreet)
assert.Equal(t, "10178", l.LocPostcode)
assert.Equal(t, "Mitte", l.LocDistrict)
assert.Equal(t, "Mitte, Berlin, Germany", l.LocLabel)
})
t.Run("NorthAtlanticOcean", func(t *testing.T) {
id := "0a3c25fcffad"
l := Location{ID: id}
if err := l.QueryPlaces(); err != nil {
t.Fatal(err)
}
assert.Equal(t, "", l.LocName)
assert.Equal(t, "", l.LocState)
assert.Equal(t, "ocean", l.LocCategory)
assert.Equal(t, "North Atlantic Ocean", l.LocDistrict)
assert.Equal(t, "North Atlantic Ocean", l.LocLabel)
})
t.Run("SouthPacificOcean", func(t *testing.T) {
id := "9aa986feefb4"
l := Location{ID: id}
if err := l.QueryPlaces(); err != nil {
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, "ec", l.LocCountry)
assert.Equal(t, "South Pacific Ocean, Ecuador", l.LocLabel)
assert.Equal(t, "places", l.LocSource)
})
}
func TestLocation_Unknown(t *testing.T) {
@ -29,7 +84,7 @@ func TestLocation_Unknown(t *testing.T) {
lng := 0.0
id := s2.Token(lat, lng)
l := NewLocation(id, "", "", "", "", "", "", "", "", []string{})
l := Location{ID: id}
assert.Equal(t, true, l.Unknown())
})
@ -38,7 +93,7 @@ func TestLocation_Unknown(t *testing.T) {
lng := 29.148046666666666
id := s2.Token(lat, lng)
l := NewLocation(id, "", "", "", "", "", "", "", "", []string{})
l := Location{ID: id}
assert.Equal(t, false, l.Unknown())
})
@ -46,7 +101,7 @@ func TestLocation_Unknown(t *testing.T) {
func TestLocation_S2Token(t *testing.T) {
t.Run("123", func(t *testing.T) {
l := NewLocation("123", "Indian ocean", "", "", "Nürnberg", "", "Bayern", "de", "", []string{})
l := Location{ID: "123"}
assert.Equal(t, "123", l.S2Token())
})
@ -54,7 +109,7 @@ func TestLocation_S2Token(t *testing.T) {
func TestLocation_PrefixedToken(t *testing.T) {
t.Run("123", func(t *testing.T) {
l := NewLocation("123", "Indian ocean", "", "", "Nürnberg", "", "Bayern", "de", "", []string{})
l := Location{ID: "123"}
assert.Equal(t, s2.TokenPrefix+"123", l.PrefixedToken())
})
@ -62,7 +117,7 @@ func TestLocation_PrefixedToken(t *testing.T) {
func TestLocation_Name(t *testing.T) {
t.Run("Christkindlesmarkt", func(t *testing.T) {
l := NewLocation("", "Christkindlesmarkt", "", "", "Nürnberg", "", "Bayern", "de", "", []string{})
l := Location{ID: "123", LocName: "Christkindlesmarkt"}
assert.Equal(t, "Christkindlesmarkt", l.Name())
})
@ -70,7 +125,7 @@ func TestLocation_Name(t *testing.T) {
func TestLocation_City(t *testing.T) {
t.Run("Nürnberg", func(t *testing.T) {
l := NewLocation("", "Christkindlesmarkt", "", "", "Nürnberg", "", "Bayern", "de", "", []string{})
l := Location{ID: "123", LocCity: "Nürnberg"}
assert.Equal(t, "Nürnberg", l.City())
})
@ -78,7 +133,7 @@ func TestLocation_City(t *testing.T) {
func TestLocation_State(t *testing.T) {
t.Run("Bayern", func(t *testing.T) {
l := NewLocation("", "Christkindlesmarkt", "", "", "Nürnberg", "", "Bayern", "de", "", []string{})
l := Location{ID: "123", LocState: "Bayern"}
assert.Equal(t, "Bayern", l.State())
})
@ -86,7 +141,7 @@ func TestLocation_State(t *testing.T) {
func TestLocation_Category(t *testing.T) {
t.Run("test", func(t *testing.T) {
l := NewLocation("", "Christkindlesmarkt", "test", "", "Nürnberg", "", "Bayern", "de", "", []string{})
l := Location{ID: "123", LocCategory: "test"}
assert.Equal(t, "test", l.Category())
})
@ -94,15 +149,15 @@ func TestLocation_Category(t *testing.T) {
func TestLocation_Source(t *testing.T) {
t.Run("source", func(t *testing.T) {
l := NewLocation("", "Christkindlesmarkt", "", "", "Nürnberg", "", "Bayern", "de", "source", []string{})
l := Location{ID: "123", LocSource: "mySource"}
assert.Equal(t, "source", l.Source())
assert.Equal(t, "mySource", l.Source())
})
}
func TestLocation_Place(t *testing.T) {
t.Run("test-label", func(t *testing.T) {
l := NewLocation("", "Christkindlesmarkt", "", "test-label", "Nürnberg", "", "Bayern", "de", "", []string{})
l := Location{ID: "123", LocLabel: "test-label"}
assert.Equal(t, "test-label", l.Label())
})
@ -110,7 +165,7 @@ func TestLocation_Place(t *testing.T) {
func TestLocation_CountryCode(t *testing.T) {
t.Run("de", func(t *testing.T) {
l := NewLocation("", "Christkindlesmarkt", "test", "test-label", "Nürnberg", "", "Bayern", "de", "", []string{})
l := Location{ID: "123", LocCountry: "de"}
assert.Equal(t, "de", l.CountryCode())
})
@ -118,14 +173,15 @@ func TestLocation_CountryCode(t *testing.T) {
func TestLocation_CountryName(t *testing.T) {
t.Run("Germany", func(t *testing.T) {
l := NewLocation("", "Christkindlesmarkt", "test", "test-label", "Nürnberg", "", "Bayern", "de", "", []string{})
l := Location{ID: "123", LocCountry: "de"}
assert.Equal(t, "Germany", l.CountryName())
})
}
func TestLocation_QueryApi(t *testing.T) {
l := NewLocation("3", "Christkindlesmarkt", "test", "test-label", "Nürnberg", "", "Bayern", "de", "", []string{})
l := Location{ID: "3", LocCountry: "de"}
t.Run("xxx", func(t *testing.T) {
api := l.QueryApi("xxx")
assert.Error(t, api, "maps: reverse lookup disabled")

View file

@ -5,7 +5,6 @@ import (
"math"
"runtime/debug"
"strconv"
"strings"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
@ -29,6 +28,17 @@ func NewMoments(conf *config.Config) *Moments {
return instance
}
// MigrateSlug updates deprecated moment slugs if needed.
func (w *Moments) MigrateSlug(m query.Moment, albumType string) {
if m.Slug() == m.TitleSlug() {
return
}
if a := entity.FindAlbumBySlug(m.TitleSlug(), albumType); a != nil {
logWarn("moments", a.Update("album_slug", m.Slug()))
}
}
// Start creates albums based on popular locations, dates and categories.
func (w *Moments) Start() (err error) {
defer func() {
@ -102,6 +112,8 @@ func (w *Moments) Start() (err error) {
log.Errorf("moments: %s", err.Error())
} else {
for _, mom := range results {
w.MigrateSlug(mom, entity.AlbumMonth)
if a := entity.FindAlbumBySlug(mom.Slug(), entity.AlbumMonth); a != nil {
if !a.Deleted() {
log.Tracef("moments: %s already exists (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
@ -125,6 +137,8 @@ func (w *Moments) Start() (err error) {
log.Errorf("moments: %s", err.Error())
} else {
for _, mom := range results {
w.MigrateSlug(mom, entity.AlbumMoment)
f := form.PhotoSearch{
Country: mom.Country,
Year: strconv.Itoa(mom.Year),
@ -156,6 +170,8 @@ func (w *Moments) Start() (err error) {
log.Errorf("moments: %s", err.Error())
} else {
for _, mom := range results {
w.MigrateSlug(mom, entity.AlbumState)
f := form.PhotoSearch{
Country: mom.Country,
State: mom.State,
@ -186,27 +202,19 @@ func (w *Moments) Start() (err error) {
log.Errorf("moments: %s", err.Error())
} else {
for _, mom := range results {
w.MigrateSlug(mom, entity.AlbumMoment)
f := form.PhotoSearch{
Label: mom.Label,
Public: true,
}
if a := entity.FindAlbumBySlug(mom.Slug(), entity.AlbumMoment); a != nil {
log.Tracef("moments: %s already exists (%s)", txt.Quote(mom.Title()), f.Serialize())
if f.Serialize() == a.AlbumFilter || a.DeletedAt != nil {
// Nothing to do.
if a.DeletedAt != nil || f.Serialize() == a.AlbumFilter {
log.Tracef("moments: %s already exists (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
continue
}
if err := form.Unserialize(&f, a.AlbumFilter); err != nil {
log.Errorf("moments: %s", err.Error())
} else {
w := txt.Words(f.Label)
w = append(w, mom.Label)
f.Label = strings.Join(txt.UniqueWords(w), txt.Or)
}
if err := a.Update("AlbumFilter", f.Serialize()); err != nil {
log.Errorf("moments: %s", err.Error())
} else {

View file

@ -31,18 +31,18 @@ func NewPlaces(conf *config.Config) *Places {
func (w *Places) Start() (updated []string, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("places: %s (panic)\nstack: %s", r, debug.Stack())
err = fmt.Errorf("index: %s (update places)\nstack: %s", r, debug.Stack())
log.Error(err)
}
}()
if err := mutex.MainWorker.Start(); err != nil {
// Already running.
log.Warnf("places: %s (start)", err.Error())
// A worker is already running.
log.Warnf("index: %s (update places)", err.Error())
return []string{}, err
} else if !w.conf.Sponsor() && !w.conf.Test() {
// Only for sponsors as this puts load on our API.
log.Warnf("places: only sponsors may fetch updated location infos")
log.Errorf(config.MsgSponsorCommand)
log.Errorf(config.MsgFundingInfo)
return []string{}, err
}
@ -51,37 +51,63 @@ func (w *Places) Start() (updated []string, err error) {
// Fetch cell IDs from index.
cells, err := query.CellIDs()
// Error?
if err != nil {
return []string{}, err
} else if len(cells) == 0 {
log.Warnf("places: found no locations")
log.Warnf("index: found no places to update")
return []string{}, nil
}
log.Infof("places: updating %s", english.Plural(len(cells), "location", "locations"))
// Drop and recreate places database table.
if err = entity.RecreateTable(entity.Place{}); err != nil {
return []string{}, fmt.Errorf("index: %s", err)
}
// List of updated cells.
updated = make([]string, 0, len(cells))
// Update known locations.
for _, id := range cells {
for i, cell := range cells {
if i%10 == 0 {
log.Infof("index: updated %s, %d remaining", english.Plural(i, "place", "places"), len(cells)-i)
}
if w.Canceled() {
return updated, nil
} else if id == "" || id == entity.UnknownID {
} else if cell.ID == "" || cell.ID == entity.UnknownID {
// Skip unknown places.
continue
}
c := entity.Cell{ID: id}
// Create cell from location and place ID.
c := entity.Cell{ID: cell.ID, PlaceID: cell.PlaceID}
// Fetch updated information from backend API.
// Fetch updated cell data from backend API.
if err = c.Refresh(entity.GeoApi); err != nil {
log.Errorf("places: %s", err)
log.Warnf("index: %s", err)
} else {
updated = append(updated, id)
// Append if successful.
updated = append(updated, cell.ID)
}
// Short break.
time.Sleep(25 * time.Millisecond)
time.Sleep(40 * time.Millisecond)
}
// Find and fix bad place ids.
log.Debug("places: updating references")
if fixed, err := query.UpdatePlaceIDs(); err != nil {
log.Errorf("places: %s (updated references)", err)
} else if fixed > 0 {
log.Infof("places: updated %d references", fixed)
}
// Update photo counts in places.
if err := entity.UpdatePlacesCounts(); err != nil {
log.Errorf("index: %s (update counts)", err)
} else {
log.Infof("index: updated counts")
}
return updated, err

View file

@ -72,8 +72,51 @@ var MomentLabels = map[string]string{
"hamster": "Pets",
}
// MomentLabelsFilter returns the smart filter string for a moment based on a matching label.
func MomentLabelsFilter(label string) string {
// TODO: Needs refactoring
label = strings.SplitN(label, txt.Or, 2)[0]
title := MomentLabels[label]
if title == "" {
return ""
}
var l []string
for i := range MomentLabels {
if MomentLabels[i] == title {
l = append(l, i)
}
}
return strings.Join(txt.UniqueWords(l), txt.Or)
}
// Slug returns an identifier string for a moment.
func (m Moment) Slug() string {
func (m Moment) Slug() (s string) {
state := txt.NormalizeState(m.State, m.Country)
if state == "" {
return m.TitleSlug()
}
country := maps.CountryName(m.Country)
if m.Year > 1900 && m.Month == 0 {
s = fmt.Sprintf("%s-%s-%04d", country, state, m.Year)
} else if m.Year > 1900 && m.Month > 0 && m.Month <= 12 {
s = fmt.Sprintf("%s-%s-%04d-%02d", country, state, m.Year, m.Month)
} else {
s = fmt.Sprintf("%s-%s", country, state)
}
return slug.Make(s)
}
// TitleSlug returns an identifier string based on the title.
func (m Moment) TitleSlug() string {
return slug.Make(m.Title())
}
@ -83,7 +126,7 @@ func (m Moment) Title() string {
if m.Year == 0 && m.Month == 0 {
if m.Label != "" {
return MomentLabels[m.Label]
return MomentLabels[strings.SplitN(m.Label, txt.Or, 2)[0]]
}
country := maps.CountryName(m.Country)
@ -93,7 +136,7 @@ func (m Moment) Title() string {
}
if state == "" {
return m.Country
return country
}
return fmt.Sprintf("%s / %s", state, country)
@ -182,6 +225,8 @@ func MomentsLabels(threshold int) (results Moments, err error) {
cats = append(cats, cat)
}
m := Moments{}
db := UnscopedDb().Table("photos").
Select("l.label_slug AS label, COUNT(*) AS photo_count").
Joins("JOIN photos_labels pl ON pl.photo_id = photos.id AND pl.uncertainty < 100").
@ -190,8 +235,23 @@ func MomentsLabels(threshold int) (results Moments, err error) {
Group("l.label_slug").
Having("photo_count >= ?", threshold)
if err := db.Scan(&results).Error; err != nil {
return results, err
if err := db.Scan(&m).Error; err != nil {
return m, err
}
done := make(map[string]bool)
for i := 0; i < len(m); i++ {
f := MomentLabelsFilter(m[i].Label)
if _, ok := done[f]; ok {
continue
} else {
done[f] = true
}
m[i].Label = f
results = append(results, m[i])
}
return results, nil

View file

@ -20,14 +20,19 @@ func TestMomentsTime(t *testing.T) {
t.Logf("MomentsTime %+v", results)
for _, moment := range results {
t.Logf("Title: %s", moment.Title())
t.Logf("Slug: %s", moment.Slug())
t.Logf("Title Slug: %s", moment.TitleSlug())
assert.Len(t, moment.Country, 0)
assert.GreaterOrEqual(t, moment.Year, 1990)
assert.LessOrEqual(t, moment.Year, 2800)
assert.GreaterOrEqual(t, moment.Month, 1)
assert.LessOrEqual(t, moment.Month, 12)
assert.GreaterOrEqual(t, moment.PhotoCount, 1)
t.Logf("Title: %s", moment.Title())
t.Logf("Slug: %s", moment.Slug())
assert.Regexp(t, "[a-zA-Z]+ [0-9]+", moment.Title())
assert.Regexp(t, "[a-z]+\\-[0-9]+", moment.Slug())
assert.Regexp(t, "[a-z]+\\-[0-9]+", moment.TitleSlug())
}
})
}
@ -47,13 +52,18 @@ func TestMomentsCountries(t *testing.T) {
}
for _, moment := range results {
t.Logf("Title: %s", moment.Title())
t.Logf("Slug: %s", moment.Slug())
t.Logf("Title Slug: %s", moment.TitleSlug())
assert.Len(t, moment.Country, 2)
assert.GreaterOrEqual(t, moment.Year, 1990)
assert.LessOrEqual(t, moment.Year, 2800)
assert.Equal(t, moment.Month, 0)
assert.GreaterOrEqual(t, moment.PhotoCount, 1)
t.Logf("Title: %s", moment.Title())
t.Logf("Slug: %s", moment.Slug())
assert.Regexp(t, "[ \\&a-zA-Z0-9]+", moment.Title())
assert.Regexp(t, "[\\-a-z0-9]+", moment.Slug())
assert.Regexp(t, "[\\-a-z0-9]+", moment.TitleSlug())
}
})
}
@ -73,13 +83,18 @@ func TestMomentsStates(t *testing.T) {
}
for _, moment := range results {
t.Logf("Title: %s", moment.Title())
t.Logf("Slug: %s", moment.Slug())
t.Logf("Title Slug: %s", moment.TitleSlug())
assert.Len(t, moment.Country, 2)
assert.NotEmpty(t, moment.State)
assert.Equal(t, moment.Year, 0)
assert.Equal(t, moment.Month, 0)
assert.GreaterOrEqual(t, moment.PhotoCount, 1)
t.Logf("Title: %s", moment.Title())
t.Logf("Slug: %s", moment.Slug())
assert.Regexp(t, "[ \\&a-zA-Z0-9]+", moment.Title())
assert.Regexp(t, "[\\-a-z0-9]+", moment.Slug())
assert.Regexp(t, "[\\-a-z0-9]+", moment.TitleSlug())
}
})
}
@ -99,14 +114,19 @@ func TestMomentsCategories(t *testing.T) {
}
for _, moment := range results {
t.Logf("Title: %s", moment.Title())
t.Logf("Slug: %s", moment.Slug())
t.Logf("Title Slug: %s", moment.TitleSlug())
assert.NotEmpty(t, moment.Label)
assert.Empty(t, moment.Country)
assert.Empty(t, moment.State)
assert.Equal(t, moment.Year, 0)
assert.Equal(t, moment.Month, 0)
assert.GreaterOrEqual(t, moment.PhotoCount, 1)
t.Logf("Title: %s", moment.Title())
t.Logf("Slug: %s", moment.Slug())
assert.Regexp(t, "[ \\&a-zA-Z0-9]+", moment.Title())
assert.Regexp(t, "[\\-a-z0-9]+", moment.Slug())
assert.Regexp(t, "[\\-a-z0-9]+", moment.TitleSlug())
}
})
}
@ -122,7 +142,7 @@ func TestMoment_Title(t *testing.T) {
PhotoCount: 0,
}
assert.Equal(t, "de", moment.Title())
assert.Equal(t, "Germany", moment.Title())
})
t.Run("country name", func(t *testing.T) {
moment := Moment{

View file

@ -1,39 +1,49 @@
package query
import (
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/entity"
)
// CellIDs returns all known S2 cell ids as string slice.
func CellIDs() (ids []string, err error) {
type Cell struct {
ID string
PlaceID string
}
type Cells []Cell
// CellIDs returns all known S2 cell ids as Cell slice.
func CellIDs() (result Cells, err error) {
tableName := entity.Cell{}.TableName()
var count int64
if err = UnscopedDb().Table(tableName).Where("id <> 'zz'").Count(&count).Error; err != nil {
return []string{}, err
return result, err
}
ids = make([]string, 0, count)
result = make(Cells, 0, count)
err = UnscopedDb().Table(tableName).Select("id").Where("id <> 'zz'").Pluck("id", &ids).Error
err = UnscopedDb().Table(tableName).Select("id, place_id").Where("id <> 'zz'").Order("id").Scan(&result).Error
return ids, err
return result, err
}
// PlaceIDs returns all known S2 place ids as string slice.
func PlaceIDs() (ids []string, err error) {
tableName := entity.Place{}.TableName()
// UpdatePlaceIDs finds and replaces invalid place references.
func UpdatePlaceIDs() (fixed int64, err error) {
photosTable := entity.Photo{}.TableName()
placesTable := entity.Place{}.TableName()
var count int64
res := Db().Table(photosTable).Where("place_id NOT IN (SELECT place_id FROM ?)", gorm.Expr(placesTable)).
UpdateColumn("place_id", "zz")
if err = UnscopedDb().Table(tableName).Where("id <> 'zz'").Count(&count).Error; err != nil {
return []string{}, err
if res.Error != nil {
return res.RowsAffected, res.Error
}
ids = make([]string, 0, count)
res = Db().Table(photosTable).Where("cell_id IS NOT NULL AND cell_id <> 'zz'").
UpdateColumn("place_id", gorm.Expr("(SELECT id FROM cells WHERE id = cell_id)"))
err = UnscopedDb().Table(tableName).Select("id").Where("id <> 'zz'").Pluck("id", &ids).Error
return ids, err
return res.RowsAffected, res.Error
}

View file

@ -16,14 +16,16 @@ func TestCellIDs(t *testing.T) {
})
}
func TestPlaceIDs(t *testing.T) {
func TestUpdatePlaceIDs(t *testing.T) {
t.Run("success", func(t *testing.T) {
result, err := PlaceIDs()
fixed, err := UpdatePlaceIDs()
if err != nil {
t.Fatal(err)
}
t.Logf("place count: %v", len(result))
if fixed < 0 {
t.Fatal("fixed must be a positive integer")
}
})
}

View file

@ -16,7 +16,7 @@ func Albums(f form.AlbumSearch) (results AlbumResults, err error) {
// Base query.
s := UnscopedDb().Table("albums").
Select("albums.*, cp.photo_count, cl.link_count").
Select("albums.*, cp.photo_count, cl.link_count, CASE WHEN albums.album_year = 0 THEN 0 ELSE 1 END AS has_year").
Joins("LEFT JOIN (SELECT album_uid, count(photo_uid) AS photo_count FROM photos_albums WHERE hidden = 0 AND missing = 0 GROUP BY album_uid) AS cp ON cp.album_uid = albums.album_uid").
Joins("LEFT JOIN (SELECT share_uid, count(share_uid) AS link_count FROM links GROUP BY share_uid) AS cl ON cl.share_uid = albums.album_uid").
Where("albums.album_type <> 'folder' OR albums.album_path IN (SELECT photo_path FROM photos WHERE photo_private = 0 AND photo_quality > -1 AND deleted_at IS NULL)").
@ -31,10 +31,30 @@ func Albums(f form.AlbumSearch) (results AlbumResults, err error) {
// Set sort order.
switch f.Order {
case "slug":
s = s.Order("albums.album_favorite DESC, album_slug ASC")
case entity.SortOrderCount:
s = s.Order("photo_count DESC, albums.album_title, albums.album_uid DESC")
case entity.SortOrderRelevance:
s = s.Order("albums.album_favorite DESC, albums.updated_at DESC, albums.album_uid DESC")
case entity.SortOrderNewest:
s = s.Order("albums.album_favorite DESC, albums.album_year DESC, albums.album_month DESC, albums.album_day DESC, albums.album_title, albums.album_uid DESC")
case entity.SortOrderOldest:
s = s.Order("albums.album_favorite DESC, albums.album_year ASC, albums.album_month ASC, albums.album_day ASC, albums.album_title, albums.album_uid ASC")
case entity.SortOrderAdded:
s = s.Order("albums.album_uid DESC")
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_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:
s = s.Order("albums.album_favorite DESC, albums.album_path DESC, albums.album_uid DESC")
case entity.SortOrderCategory:
s = s.Order("albums.album_category, albums.album_title, albums.album_uid DESC")
case entity.SortOrderSlug:
s = s.Order("albums.album_favorite DESC, albums.album_slug ASC, albums.album_uid DESC")
default:
s = s.Order("albums.album_favorite DESC, albums.album_year DESC, albums.album_month DESC, albums.album_day DESC, albums.album_title, albums.created_at DESC")
s = s.Order("albums.album_favorite DESC, has_year, albums.album_title ASC, albums.album_uid DESC")
}
if f.ID != "" {

View file

@ -53,7 +53,7 @@ func TestAlbums(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "April 1990", result[0].AlbumTitle)
assert.Equal(t, "Import Album", result[0].AlbumTitle)
})
t.Run("favorites true", func(t *testing.T) {

View file

@ -40,6 +40,9 @@ func TestUcFirst(t *testing.T) {
t.Run("cat", func(t *testing.T) {
assert.Equal(t, "Cat", UcFirst("Cat"))
})
t.Run("KwaZulu natal", func(t *testing.T) {
assert.Equal(t, "KwaZulu Natal", Title("KwaZulu natal"))
})
t.Run("empty string", func(t *testing.T) {
assert.Equal(t, "", UcFirst(""))
})