People: Add keywords, subjects, and albums search filters #22 #882

This commit is contained in:
Michael Mayer 2021-08-29 16:16:49 +02:00
parent a0f49f2d56
commit 11d1034752
21 changed files with 525 additions and 222 deletions

View file

@ -10,6 +10,8 @@ import (
"github.com/photoprism/photoprism/internal/query"
)
// GetPhotos searches the pictures index and returns the result as JSON.
//
// GET /api/v1/photos
//
// Query:

View file

@ -34,7 +34,7 @@ type Album struct {
AlbumSlug string `gorm:"type:VARBINARY(255);index;" json:"Slug" yaml:"Slug"`
AlbumPath string `gorm:"type:VARBINARY(500);index;" json:"Path" yaml:"-"`
AlbumType string `gorm:"type:VARBINARY(8);default:'album';" json:"Type" yaml:"Type,omitempty"`
AlbumTitle string `gorm:"type:VARCHAR(255);" json:"Title" yaml:"Title"`
AlbumTitle string `gorm:"type:VARCHAR(255);index;" json:"Title" yaml:"Title"`
AlbumLocation string `gorm:"type:VARCHAR(255);" json:"Location" yaml:"Location,omitempty"`
AlbumCategory string `gorm:"type:VARCHAR(255);index;" json:"Category" yaml:"Category,omitempty"`
AlbumCaption string `gorm:"type:TEXT;" json:"Caption" yaml:"Caption,omitempty"`

View file

@ -22,7 +22,7 @@ var MarkerFixtures = MarkerMap{
"1000003-1": Marker{
ID: 1,
FileID: 1000003,
SubjectUID: "lt9k3pw1wowuy3c3",
SubjectUID: "jqu0xs11qekk9jx8",
MarkerSrc: SrcImage,
MarkerType: MarkerLabel,
X: 0.308333,
@ -35,7 +35,7 @@ var MarkerFixtures = MarkerMap{
"1000003-2": Marker{
ID: 2,
FileID: 1000003,
SubjectUID: "",
SubjectUID: "lt9k3pw1wowuy3c3",
FaceID: "LRG2HJBDZE66LYG7Q5SRFXO2MDTOES52",
MarkerName: "Unknown",
MarkerSrc: SrcImage,

View file

@ -37,7 +37,7 @@ func TestAccountSearch_ParseQueryString(t *testing.T) {
t.FailNow()
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "webdäv", form.Query)
assert.Equal(t, true, form.Share)
@ -54,7 +54,7 @@ func TestAccountSearch_ParseQueryString(t *testing.T) {
t.FailNow()
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "unknown filter: Xxx", err.Error())
})
@ -78,7 +78,7 @@ func TestAccountSearch_ParseQueryString(t *testing.T) {
t.FailNow()
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "strconv.Atoi: parsing \"cat\": invalid syntax", err.Error())
})

View file

@ -13,13 +13,12 @@ func TestAlbumSearchForm(t *testing.T) {
}
func TestParseQueryStringAlbum(t *testing.T) {
t.Run("valid query", func(t *testing.T) {
form := &AlbumSearch{Query: "slug:album1 favorite:true count:10"}
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal("err should be nil")
@ -34,7 +33,7 @@ func TestParseQueryStringAlbum(t *testing.T) {
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal("err should be nil")
@ -51,7 +50,7 @@ func TestParseQueryStringAlbum(t *testing.T) {
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal("err should be nil")
@ -68,7 +67,7 @@ func TestParseQueryStringAlbum(t *testing.T) {
t.FailNow()
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "unknown filter: Xxx", err.Error())
})
@ -92,7 +91,7 @@ func TestParseQueryStringAlbum(t *testing.T) {
t.FailNow()
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "strconv.Atoi: parsing \"cat\": invalid syntax", err.Error())
})

View file

@ -44,7 +44,7 @@ func TestParseQueryStringFolder(t *testing.T) {
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal("err should be nil")
@ -62,7 +62,7 @@ func TestParseQueryStringFolder(t *testing.T) {
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal("err should be nil")
@ -79,7 +79,7 @@ func TestParseQueryStringFolder(t *testing.T) {
t.FailNow()
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "unknown filter: Xxx", err.Error())
})
@ -103,7 +103,7 @@ func TestParseQueryStringFolder(t *testing.T) {
t.FailNow()
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "strconv.Atoi: parsing \"cat\": invalid syntax", err.Error())
})

View file

@ -25,7 +25,12 @@ type GeoSearch struct {
S2 string `form:"s2"`
Olc string `form:"olc"`
Dist uint `form:"dist"`
Subject string `form:"subject"` // UIDs
Subjects string `form:"subjects"` // Text
People string `form:"people"` // Alias for Subjects
Keywords string `form:"keywords"`
Album string `form:"album"`
Albums string `form:"albums"`
Country string `form:"country"`
Year int `form:"year"` // Moments
Month int `form:"month"` // Moments
@ -53,6 +58,10 @@ func (f *GeoSearch) ParseQueryString() error {
f.Path = f.Folder
}
if f.Subjects == "" {
f.Subjects = f.People
}
return err
}

View file

@ -8,6 +8,28 @@ import (
)
func TestGeoSearch(t *testing.T) {
t.Run("subjects", func(t *testing.T) {
form := &GeoSearch{Query: "subjects:\"Jens Mander\""}
err := form.ParseQueryString()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Jens Mander", form.Subjects)
})
t.Run("keywords", func(t *testing.T) {
form := &GeoSearch{Query: "keywords:\"Foo Bar\""}
err := form.ParseQueryString()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Foo Bar", form.Keywords)
})
t.Run("valid query", func(t *testing.T) {
form := &GeoSearch{Query: "query:\"fooBar baz\" before:2019-01-15 dist:25000 lat:33.45343166666667"}
@ -17,7 +39,7 @@ func TestGeoSearch(t *testing.T) {
t.Fatal("err should be nil")
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "fooBar baz", form.Query)
assert.Equal(t, time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), form.Before)
@ -33,7 +55,7 @@ func TestGeoSearch(t *testing.T) {
t.Fatal("err should be nil")
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "fooBar baz", form.Query)
assert.Equal(t, "test", form.Path)

View file

@ -19,7 +19,7 @@ func TestParseQueryStringLabel(t *testing.T) {
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal("err should be nil")
@ -35,7 +35,7 @@ func TestParseQueryStringLabel(t *testing.T) {
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal("err should be nil")
@ -50,7 +50,7 @@ func TestParseQueryStringLabel(t *testing.T) {
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal("err should be nil")
@ -67,7 +67,7 @@ func TestParseQueryStringLabel(t *testing.T) {
t.Fatal("err should NOT be nil")
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "unknown filter: Xxx", err.Error())
})
@ -91,7 +91,7 @@ func TestParseQueryStringLabel(t *testing.T) {
t.Fatal("err should NOT be nil")
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "strconv.Atoi: parsing \"2019-01-15\": invalid syntax", err.Error())
})

View file

@ -42,7 +42,12 @@ type PhotoSearch struct {
Mono bool `form:"mono"`
Portrait bool `form:"portrait"`
Geo bool `form:"geo"`
Album string `form:"album"`
Subject string `form:"subject"` // UIDs
Subjects string `form:"subjects"` // Text
People string `form:"people"` // Alias for Subjects
Keywords string `form:"keywords"`
Album string `form:"album"` // UIDs
Albums string `form:"albums"` // Text
Label string `form:"label"`
Category string `form:"category"` // Moments
Country string `form:"country"` // Moments
@ -81,6 +86,10 @@ func (f *PhotoSearch) ParseQueryString() error {
f.Path = f.Folder
}
if f.Subjects == "" {
f.Subjects = f.People
}
if f.Filter != "" {
if err := Unserialize(f, f.Filter); err != nil {
return err

View file

@ -14,12 +14,47 @@ func TestPhotoSearchForm(t *testing.T) {
}
func TestParseQueryString(t *testing.T) {
t.Run("subjects", func(t *testing.T) {
form := &PhotoSearch{Query: "subjects:\"Jens & Mander\""}
err := form.ParseQueryString()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Jens & Mander", form.Subjects)
})
t.Run("keywords", func(t *testing.T) {
form := &PhotoSearch{Query: "keywords:\"Foo Bar\""}
err := form.ParseQueryString()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Foo Bar", form.Keywords)
})
t.Run("and query", func(t *testing.T) {
form := &PhotoSearch{Query: "\"Jens & Mander\" title:\"Tübingen\""}
err := form.ParseQueryString()
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "jens & mander", form.GetQuery())
assert.Equal(t, "Tübingen", form.Title)
})
t.Run("path", func(t *testing.T) {
form := &PhotoSearch{Query: "path:123abc/,EFG"}
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal(err)
@ -33,7 +68,7 @@ func TestParseQueryString(t *testing.T) {
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal(err)
@ -46,7 +81,7 @@ func TestParseQueryString(t *testing.T) {
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal(err)
@ -65,7 +100,7 @@ func TestParseQueryString(t *testing.T) {
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal("err should be nil")
@ -81,7 +116,7 @@ func TestParseQueryString(t *testing.T) {
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal(err)
@ -96,7 +131,7 @@ func TestParseQueryString(t *testing.T) {
err := form.ParseQueryString()
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
if err != nil {
t.Fatal(err)
@ -113,7 +148,7 @@ func TestParseQueryString(t *testing.T) {
t.Fatal(err)
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "unknown filter: Xxx", err.Error())
})
@ -137,7 +172,7 @@ func TestParseQueryString(t *testing.T) {
t.Fatal(err)
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "strconv.ParseFloat: parsing \"cat\": invalid syntax", err.Error())
})
@ -150,7 +185,7 @@ func TestParseQueryString(t *testing.T) {
t.Fatal(err)
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "strconv.Atoi: parsing \"cat\": invalid syntax", err.Error())
})
@ -163,7 +198,7 @@ func TestParseQueryString(t *testing.T) {
t.Fatal(err)
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "strconv.Atoi: parsing \"cat\": invalid syntax", err.Error())
})
@ -176,7 +211,7 @@ func TestParseQueryString(t *testing.T) {
t.Fatal(err)
}
log.Debugf("%+v\n", form)
// log.Debugf("%+v\n", form)
assert.Equal(t, "Could not find format for \"cat\"", err.Error())
})

View file

@ -54,8 +54,8 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) {
if err := Db().Where(AnySlug("custom_slug", f.Query, " ")).Find(&labels).Error; len(labels) == 0 || err != nil {
log.Infof("search: label %s not found, using fuzzy search", txt.Quote(f.Query))
if likeAny := LikeAny("k.keyword", f.Query); likeAny != "" {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(likeAny))
for _, where := range LikeAny("k.keyword", f.Query) {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
}
} else {
for _, l := range labels {
@ -70,35 +70,66 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) {
}
}
if likeAny := LikeAny("k.keyword", f.Query); likeAny != "" {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?)) OR "+
"photos.id IN (SELECT pl.photo_id FROM photos_labels pl WHERE pl.uncertainty < 100 AND pl.label_id IN (?))", gorm.Expr(likeAny), labelIds)
if wheres := LikeAny("k.keyword", f.Query); len(wheres) > 0 {
for _, where := range wheres {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?)) OR "+
"photos.id IN (SELECT pl.photo_id FROM photos_labels pl WHERE pl.uncertainty < 100 AND pl.label_id IN (?))", gorm.Expr(where), labelIds)
}
} else {
s = s.Where("photos.id IN (SELECT pl.photo_id FROM photos_labels pl WHERE pl.uncertainty < 100 AND pl.label_id IN (?))", labelIds)
}
}
}
if f.Album != "" {
s = s.Joins("JOIN photos_albums ON photos_albums.photo_uid = photos.photo_uid").Where("photos_albums.hidden = 0 AND photos_albums.album_uid = ?", f.Album)
// Search for one or more keywords?
if f.Keywords != "" {
for _, where := range LikeAll("k.keyword", f.Keywords) {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
}
}
// Filter for one or more subjects?
if f.Subject != "" {
s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.id = m.file_id AND m.marker_invalid = 0 WHERE subject_uid IN (?))",
entity.Marker{}.TableName()), strings.Split(strings.ToLower(f.Subject), Or))
} else if f.Subjects != "" {
for _, where := range LikeAny("s.subject_name", f.Subjects) {
s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.id = m.file_id AND m.marker_invalid = 0 JOIN %s s ON s.subject_uid = m.subject_uid WHERE (?))",
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(where))
}
}
// Filter by album?
if f.Album != "" {
s = s.Joins("JOIN photos_albums ON photos_albums.photo_uid = photos.photo_uid").
Where("photos_albums.hidden = 0 AND photos_albums.album_uid = ?", f.Album)
} else if f.Albums != "" {
for _, where := range LikeAny("a.album_title", f.Albums) {
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid WHERE (?))", gorm.Expr(where))
}
}
// Filter by camera?
if f.Camera > 0 {
s = s.Where("photos.camera_id = ?", f.Camera)
}
// Filter by camera lens?
if f.Lens > 0 {
s = s.Where("photos.lens_id = ?", f.Lens)
}
// Filter by year?
if (f.Year > 0 && f.Year <= txt.YearMax) || f.Year == entity.UnknownYear {
s = s.Where("photos.photo_year = ?", f.Year)
}
// Filter by month?
if (f.Month >= txt.MonthMin && f.Month <= txt.MonthMax) || f.Month == entity.UnknownMonth {
s = s.Where("photos.photo_month = ?", f.Month)
}
// Filter by day?
if (f.Day >= txt.DayMin && f.Month <= txt.DayMax) || f.Day == entity.UnknownDay {
s = s.Where("photos.photo_day = ?", f.Day)
}

View file

@ -11,14 +11,32 @@ import (
)
func TestGeo(t *testing.T) {
t.Run("search all photos", func(t *testing.T) {
query := form.NewGeoSearch("")
result, err := Geo(query)
t.Run("form.keywords", func(t *testing.T) {
query := form.NewGeoSearch("keywords:bridge")
if err != nil {
if result, err := Geo(query); err != nil {
t.Fatal(err)
} else {
assert.GreaterOrEqual(t, len(result), 1)
}
})
t.Run("form.subjects", func(t *testing.T) {
query := form.NewGeoSearch("subjects:John")
if result, err := Geo(query); err != nil {
t.Fatal(err)
} else {
assert.GreaterOrEqual(t, len(result), 0)
}
})
t.Run("find_all", func(t *testing.T) {
query := form.NewGeoSearch("")
if result, err := Geo(query); err != nil {
t.Fatal(err)
} else {
assert.LessOrEqual(t, 4, len(result))
}
assert.LessOrEqual(t, 4, len(result))
})
t.Run("search for bridge", func(t *testing.T) {

111
internal/query/like.go Normal file
View file

@ -0,0 +1,111 @@
package query
import (
"fmt"
"strings"
"github.com/gosimple/slug"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/jinzhu/inflection"
)
// LikeAny returns a single where condition matching the search keywords.
func LikeAny(col, keywords string) (wheres []string) {
keywords = strings.ReplaceAll(keywords, Or, " ")
keywords = strings.ReplaceAll(keywords, OrEn, " ")
keywords = strings.ReplaceAll(keywords, AndEn, And)
for _, k := range strings.Split(keywords, And) {
var orWheres []string
words := txt.UniqueKeywords(k)
if len(words) == 0 {
continue
}
for _, w := range words {
if len(w) > 3 {
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s%%'", col, w))
} else {
orWheres = append(orWheres, fmt.Sprintf("%s = '%s'", col, w))
}
if !txt.ContainsASCIILetters(w) {
continue
}
singular := inflection.Singular(w)
if singular != w {
orWheres = append(orWheres, fmt.Sprintf("%s = '%s'", col, singular))
}
}
if len(orWheres) > 0 {
wheres = append(wheres, strings.Join(orWheres, " OR "))
}
}
return wheres
}
// LikeAll returns a list of where conditions matching all search keywords.
func LikeAll(col, keywords string) (wheres []string) {
words := txt.UniqueKeywords(keywords)
if len(words) == 0 {
return wheres
}
for _, w := range words {
if len(w) > 3 {
wheres = append(wheres, fmt.Sprintf("%s LIKE '%s%%'", col, w))
} else {
wheres = append(wheres, fmt.Sprintf("%s = '%s'", col, w))
}
}
return wheres
}
// AnySlug returns a where condition that matches any slug in search.
func AnySlug(col, search, sep string) (where string) {
if search == "" {
return ""
}
if sep == "" {
sep = " "
}
var wheres []string
var words []string
for _, w := range strings.Split(search, sep) {
w = strings.TrimSpace(w)
words = append(words, slug.Make(w))
if !txt.ContainsASCIILetters(w) {
continue
}
singular := inflection.Singular(w)
if singular != w {
words = append(words, slug.Make(singular))
}
}
if len(words) == 0 {
return ""
}
for _, w := range words {
wheres = append(wheres, fmt.Sprintf("%s = '%s'", col, w))
}
return strings.Join(wheres, " OR ")
}

123
internal/query/like_test.go Normal file
View file

@ -0,0 +1,123 @@
package query
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLikeAny(t *testing.T) {
t.Run("and_or_search", func(t *testing.T) {
if w := LikeAny("k.keyword", "table spoon & usa | img json"); len(w) != 2 {
t.Fatal("two where conditions expected")
} else {
assert.Equal(t, "k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%'", w[0])
assert.Equal(t, "k.keyword LIKE 'json%' OR k.keyword = 'usa'", w[1])
}
})
t.Run("and_or_search_en", func(t *testing.T) {
if w := LikeAny("k.keyword", "table spoon and usa or img json"); len(w) != 2 {
t.Fatal("two where conditions expected")
} else {
assert.Equal(t, "k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%'", w[0])
assert.Equal(t, "k.keyword LIKE 'json%' OR k.keyword = 'usa'", w[1])
}
})
t.Run("table spoon usa img json", func(t *testing.T) {
if w := LikeAny("k.keyword", "table spoon usa img json"); len(w) != 1 {
t.Fatal("one where condition expected")
} else {
assert.Equal(t, "k.keyword LIKE 'json%' OR k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%' OR k.keyword = 'usa'", w[0])
}
})
t.Run("cat dog", func(t *testing.T) {
if w := LikeAny("k.keyword", "cat dog"); len(w) != 1 {
t.Fatal("one where condition expected")
} else {
assert.Equal(t, "k.keyword = 'cat' OR k.keyword = 'dog'", w[0])
}
})
t.Run("cats dogs", func(t *testing.T) {
if w := LikeAny("k.keyword", "cats dogs"); len(w) != 1 {
t.Fatal("one where condition expected")
} else {
assert.Equal(t, "k.keyword LIKE 'cats%' OR k.keyword = 'cat' OR k.keyword LIKE 'dogs%' OR k.keyword = 'dog'", w[0])
}
})
t.Run("spoon", func(t *testing.T) {
if w := LikeAny("k.keyword", "spoon"); len(w) != 1 {
t.Fatal("one where condition expected")
} else {
assert.Equal(t, "k.keyword LIKE 'spoon%'", w[0])
}
})
t.Run("img", func(t *testing.T) {
if w := LikeAny("k.keyword", "img"); len(w) > 0 {
t.Fatal("no where condition expected")
}
})
t.Run("empty", func(t *testing.T) {
if w := LikeAny("k.keyword", ""); len(w) > 0 {
t.Fatal("no where condition expected")
}
})
}
func TestLikeAll(t *testing.T) {
t.Run("keywords", func(t *testing.T) {
if w := LikeAll("k.keyword", "Jo Mander 李"); len(w) == 2 {
assert.Equal(t, "k.keyword LIKE 'mander%'", w[0])
assert.Equal(t, "k.keyword = '李'", w[1])
} else {
t.Logf("wheres: %#v", w)
t.Fatal("two where conditions expected")
}
})
}
func TestAnySlug(t *testing.T) {
t.Run("table spoon usa img json", func(t *testing.T) {
where := AnySlug("custom_slug", "table spoon usa img json", " ")
assert.Equal(t, "custom_slug = 'table' OR custom_slug = 'spoon' OR custom_slug = 'usa' OR custom_slug = 'img' OR custom_slug = 'json'", where)
})
t.Run("cat dog", func(t *testing.T) {
where := AnySlug("custom_slug", "cat dog", " ")
assert.Equal(t, "custom_slug = 'cat' OR custom_slug = 'dog'", where)
})
t.Run("cats dogs", func(t *testing.T) {
where := AnySlug("custom_slug", "cats dogs", " ")
assert.Equal(t, "custom_slug = 'cats' OR custom_slug = 'cat' OR custom_slug = 'dogs' OR custom_slug = 'dog'", where)
})
t.Run("spoon", func(t *testing.T) {
where := AnySlug("custom_slug", "spoon", " ")
assert.Equal(t, "custom_slug = 'spoon'", where)
})
t.Run("img", func(t *testing.T) {
where := AnySlug("custom_slug", "img", " ")
assert.Equal(t, "custom_slug = 'img'", where)
})
t.Run("empty", func(t *testing.T) {
where := AnySlug("custom_slug", "", " ")
assert.Equal(t, "", where)
})
t.Run("comma separated", func(t *testing.T) {
where := AnySlug("custom_slug", "botanical-garden|landscape|bay", Or)
assert.Equal(t, "custom_slug = 'botanical-garden' OR custom_slug = 'landscape' OR custom_slug = 'bay'", where)
})
t.Run("len = 0", func(t *testing.T) {
where := AnySlug("custom_slug", " ", "")
assert.Equal(t, "custom_slug = '' OR custom_slug = ''", where)
})
}

View file

@ -136,15 +136,15 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
if f.Geo == true {
s = s.Where("photos.cell_id <> 'zz'")
if likeAny := LikeAny("k.keyword", f.Query); likeAny != "" {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(likeAny))
for _, where := range LikeAny("k.keyword", f.Query) {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
}
} else if f.Query != "" {
if err := Db().Where(AnySlug("custom_slug", f.Query, " ")).Find(&labels).Error; len(labels) == 0 || err != nil {
log.Infof("search: label %s not found, using fuzzy search", txt.Quote(f.Query))
if likeAny := LikeAny("k.keyword", f.Query); likeAny != "" {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(likeAny))
for _, where := range LikeAny("k.keyword", f.Query) {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
}
} else {
for _, l := range labels {
@ -159,16 +159,36 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
}
}
if likeAny := LikeAny("k.keyword", f.Query); likeAny != "" {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?)) OR "+
"photos.id IN (SELECT pl.photo_id FROM photos_labels pl WHERE pl.uncertainty < 100 AND pl.label_id IN (?))", gorm.Expr(likeAny), labelIds)
if wheres := LikeAny("k.keyword", f.Query); len(wheres) > 0 {
for _, where := range wheres {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?)) OR "+
"photos.id IN (SELECT pl.photo_id FROM photos_labels pl WHERE pl.uncertainty < 100 AND pl.label_id IN (?))", gorm.Expr(where), labelIds)
}
} else {
s = s.Where("photos.id IN (SELECT pl.photo_id FROM photos_labels pl WHERE pl.uncertainty < 100 AND pl.label_id IN (?))", labelIds)
}
}
}
// Filter by status.
// Search for one or more keywords?
if f.Keywords != "" {
for _, where := range LikeAll("k.keyword", f.Keywords) {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
}
}
// Filter for one or more subjects?
if f.Subject != "" {
s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.id = m.file_id AND m.marker_invalid = 0 WHERE subject_uid IN (?))",
entity.Marker{}.TableName()), strings.Split(strings.ToLower(f.Subject), Or))
} else if f.Subjects != "" {
for _, where := range LikeAny("s.subject_name", f.Subjects) {
s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.id = m.file_id AND m.marker_invalid = 0 JOIN %s s ON s.subject_uid = m.subject_uid WHERE (?))",
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(where))
}
}
// Filter by status?
if f.Hidden {
s = s.Where("photos.photo_quality = -1")
s = s.Where("photos.deleted_at IS NULL")
@ -191,23 +211,27 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
}
}
// Filter by additional flags and metadata.
// Filter by camera?
if f.Camera > 0 {
s = s.Where("photos.camera_id = ?", f.Camera)
}
// Filter by camera lens?
if f.Lens > 0 {
s = s.Where("photos.lens_id = ?", f.Lens)
}
// Filter by year?
if (f.Year > 0 && f.Year <= txt.YearMax) || f.Year == entity.UnknownYear {
s = s.Where("photos.photo_year = ?", f.Year)
}
// Filter by month?
if (f.Month >= txt.MonthMin && f.Month <= txt.MonthMax) || f.Month == entity.UnknownMonth {
s = s.Where("photos.photo_month = ?", f.Month)
}
// Filter by day?
if (f.Day >= txt.DayMin && f.Month <= txt.DayMax) || f.Day == entity.UnknownDay {
s = s.Where("photos.photo_day = ?", f.Day)
}
@ -363,10 +387,12 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
s = s.Where("photos.taken_at >= ?", f.After.Format("2006-01-02"))
}
// Find stacks only?
if f.Stack {
s = s.Where("photos.id IN (SELECT a.photo_id FROM files a JOIN files b ON a.id != b.id AND a.photo_id = b.photo_id AND a.file_type = b.file_type WHERE a.file_type='jpg')")
}
// Filter by album?
if f.Album != "" {
if f.Filter != "" {
s = s.Where("photos.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 1 AND pa.album_uid = ?)", f.Album)
@ -375,6 +401,10 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
}
} else if f.Unsorted && f.Filter == "" {
s = s.Where("photos.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 0)")
} else if f.Albums != "" {
for _, where := range LikeAny("a.album_title", f.Albums) {
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid WHERE (?))", gorm.Expr(where))
}
}
if err := s.Scan(&results).Error; err != nil {

View file

@ -339,9 +339,69 @@ func TestPhotoSearch(t *testing.T) {
}
//t.Logf("results: %+v", photos)
assert.Equal(t, 1, len(photos))
assert.GreaterOrEqual(t, len(photos), 1)
})
t.Run("form.keywords", func(t *testing.T) {
var f form.PhotoSearch
f.Query = "keywords:bridge"
f.Count = 10
f.Offset = 0
photos, _, err := PhotoSearch(f)
if err != nil {
t.Fatal(err)
}
//t.Logf("results: %+v", photos)
assert.GreaterOrEqual(t, len(photos), 4)
})
t.Run("form.subject", func(t *testing.T) {
var f form.PhotoSearch
f.Query = "subject:jqu0xs11qekk9jx8"
f.Count = 10
f.Offset = 0
photos, _, err := PhotoSearch(f)
if err != nil {
t.Fatal(err)
}
//t.Logf("results: %+v", photos)
assert.Equal(t, 1, len(photos))
})
t.Run("form.subjects", func(t *testing.T) {
var f form.PhotoSearch
f.Query = "subjects:John"
f.Count = 10
f.Offset = 0
photos, _, err := PhotoSearch(f)
if err != nil {
t.Fatal(err)
}
//t.Logf("results: %+v", photos)
assert.Equal(t, 1, len(photos))
})
t.Run("form.people", func(t *testing.T) {
var f form.PhotoSearch
f.Query = "people:John"
f.Count = 10
f.Offset = 0
photos, _, err := PhotoSearch(f)
if err != nil {
t.Fatal(err)
}
//t.Logf("results: %+v", photos)
assert.Equal(t, 1, len(photos))
})
t.Run("form.hash", func(t *testing.T) {
var f form.PhotoSearch
f.Query = "hash:2cad9168fa6acc5c5c2965ddf6ec465ca42fd818"
@ -522,6 +582,18 @@ func TestPhotoSearch(t *testing.T) {
}
assert.LessOrEqual(t, 1, len(photos))
})
t.Run("albums", func(t *testing.T) {
var f form.PhotoSearch
f.Query = ""
f.Albums = "Berlin"
photos, _, err := PhotoSearch(f)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 1, len(photos))
})
t.Run("search for state", func(t *testing.T) {
var f form.PhotoSearch
f.State = "KwaZulu-Natal"
@ -728,6 +800,6 @@ func TestPhotoSearch(t *testing.T) {
t.Fatal(err)
}
assert.GreaterOrEqual(t, 3, len(photos))
assert.GreaterOrEqual(t, len(photos), 3)
})
}

View file

@ -32,16 +32,9 @@ https://docs.photoprism.org/developer-guide/
package query
import (
"fmt"
"strings"
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/jinzhu/gorm"
"github.com/jinzhu/inflection"
)
var log = event.Log
@ -50,12 +43,15 @@ const (
MySQL = "mysql"
SQLite = "sqlite3"
Or = "|"
And = "&"
OrEn = " or "
AndEn = " and "
)
// Max result limit for queries.
// MaxResults is max result limit for queries.
const MaxResults = 10000
// About 1km ('good enough' for now)
// SearchRadius is about 1 km.
const SearchRadius = 0.009
// Query searches given an originals path and a db instance.
@ -91,74 +87,3 @@ func UnscopedDb() *gorm.DB {
func DbDialect() string {
return Db().Dialect().GetName()
}
// LikeAny returns a where condition that matches any keyword in search.
func LikeAny(col, search string) (where string) {
var wheres []string
words := txt.UniqueKeywords(search)
if len(words) == 0 {
return ""
}
for _, w := range words {
if len(w) > 3 {
wheres = append(wheres, fmt.Sprintf("%s LIKE '%s%%'", col, w))
} else {
wheres = append(wheres, fmt.Sprintf("%s = '%s'", col, w))
}
if !txt.ContainsASCIILetters(w) {
continue
}
singular := inflection.Singular(w)
if singular != w {
wheres = append(wheres, fmt.Sprintf("%s = '%s'", col, singular))
}
}
return strings.Join(wheres, " OR ")
}
// AnySlug returns a where condition that matches any slug in search.
func AnySlug(col, search, sep string) (where string) {
if search == "" {
return ""
}
if sep == "" {
sep = " "
}
var wheres []string
var words []string
for _, w := range strings.Split(search, sep) {
w = strings.TrimSpace(w)
words = append(words, slug.Make(w))
if !txt.ContainsASCIILetters(w) {
continue
}
singular := inflection.Singular(w)
if singular != w {
words = append(words, slug.Make(singular))
}
}
if len(words) == 0 {
return ""
}
for _, w := range words {
wheres = append(wheres, fmt.Sprintf("%s = '%s'", col, w))
}
return strings.Join(wheres, " OR ")
}

View file

@ -6,7 +6,6 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
@ -24,77 +23,3 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
func TestLikeAny(t *testing.T) {
t.Run("table spoon usa img json", func(t *testing.T) {
where := LikeAny("k.keyword", "table spoon usa img json")
assert.Equal(t, "k.keyword LIKE 'json%' OR k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%' OR k.keyword = 'usa'", where)
})
t.Run("cat dog", func(t *testing.T) {
where := LikeAny("k.keyword", "cat dog")
assert.Equal(t, "k.keyword = 'cat' OR k.keyword = 'dog'", where)
})
t.Run("cats dogs", func(t *testing.T) {
where := LikeAny("k.keyword", "cats dogs")
assert.Equal(t, "k.keyword LIKE 'cats%' OR k.keyword = 'cat' OR k.keyword LIKE 'dogs%' OR k.keyword = 'dog'", where)
})
t.Run("spoon", func(t *testing.T) {
where := LikeAny("k.keyword", "spoon")
assert.Equal(t, "k.keyword LIKE 'spoon%'", where)
})
t.Run("img", func(t *testing.T) {
where := LikeAny("k.keyword", "img")
assert.Equal(t, "", where)
})
t.Run("empty", func(t *testing.T) {
where := LikeAny("k.keyword", "")
assert.Equal(t, "", where)
})
}
func TestAnySlug(t *testing.T) {
t.Run("table spoon usa img json", func(t *testing.T) {
where := AnySlug("custom_slug", "table spoon usa img json", " ")
assert.Equal(t, "custom_slug = 'table' OR custom_slug = 'spoon' OR custom_slug = 'usa' OR custom_slug = 'img' OR custom_slug = 'json'", where)
})
t.Run("cat dog", func(t *testing.T) {
where := AnySlug("custom_slug", "cat dog", " ")
assert.Equal(t, "custom_slug = 'cat' OR custom_slug = 'dog'", where)
})
t.Run("cats dogs", func(t *testing.T) {
where := AnySlug("custom_slug", "cats dogs", " ")
assert.Equal(t, "custom_slug = 'cats' OR custom_slug = 'cat' OR custom_slug = 'dogs' OR custom_slug = 'dog'", where)
})
t.Run("spoon", func(t *testing.T) {
where := AnySlug("custom_slug", "spoon", " ")
assert.Equal(t, "custom_slug = 'spoon'", where)
})
t.Run("img", func(t *testing.T) {
where := AnySlug("custom_slug", "img", " ")
assert.Equal(t, "custom_slug = 'img'", where)
})
t.Run("empty", func(t *testing.T) {
where := AnySlug("custom_slug", "", " ")
assert.Equal(t, "", where)
})
t.Run("comma separated", func(t *testing.T) {
where := AnySlug("custom_slug", "botanical-garden|landscape|bay", Or)
assert.Equal(t, "custom_slug = 'botanical-garden' OR custom_slug = 'landscape' OR custom_slug = 'bay'", where)
})
t.Run("len = 0", func(t *testing.T) {
where := AnySlug("custom_slug", " ", "")
assert.Equal(t, "custom_slug = '' OR custom_slug = ''", where)
})
}

View file

@ -1428,7 +1428,6 @@ manchem
manchen
mancher
manches
mann
mehr
mein
meine
@ -1436,9 +1435,6 @@ meinem
meinen
meiner
meines
mensch
menschen
mich
mir
mit
mittel

View file

@ -1433,7 +1433,6 @@ var StopWords = map[string]bool{
"manchen": true,
"mancher": true,
"manches": true,
"mann": true,
"mehr": true,
"mein": true,
"meine": true,
@ -1441,9 +1440,6 @@ var StopWords = map[string]bool{
"meinen": true,
"meiner": true,
"meines": true,
"mensch": true,
"menschen": true,
"mich": true,
"mir": true,
"mit": true,
"mittel": true,