Search: Default to photo names and keywords #1517 #1560

Default to photo name when search term is too short or on the stop list.
Search full text index otherwise, which now include names of people
(requires reindexing).
This commit is contained in:
Michael Mayer 2021-09-29 20:09:34 +02:00
parent 13d1abfb0d
commit 24eff21aa4
34 changed files with 449 additions and 119 deletions

View file

@ -26,7 +26,7 @@ func findFileMarker(c *gin.Context) (file *entity.File, marker *entity.Marker, e
// Check feature flags.
conf := service.Config()
if !conf.Settings().Features.People || !conf.Settings().Features.Edit {
if !conf.Settings().Features.People {
AbortFeatureDisabled(c)
return nil, nil, fmt.Errorf("feature disabled")
}
@ -70,27 +70,27 @@ func UpdateMarker(router *gin.RouterGroup) {
file, marker, err := findFileMarker(c)
if err != nil {
log.Debugf("api: %s (update marker)", err)
log.Debugf("marker: %s (find)", err)
return
}
markerForm, err := form.NewMarker(*marker)
if err != nil {
log.Errorf("photo: %s (new marker form)", err)
log.Errorf("marker: %s (new form)", err)
AbortSaveFailed(c)
return
}
if err := c.BindJSON(&markerForm); err != nil {
log.Errorf("photo: %s (update marker form)", err)
log.Errorf("marker: %s (update form)", err)
AbortBadRequest(c)
return
}
// Save marker.
if changed, err := marker.SaveForm(markerForm); err != nil {
log.Errorf("photo: %s (save marker form)", err)
log.Errorf("marker: %s", err)
AbortSaveFailed(c)
return
} else if changed {

View file

@ -106,7 +106,8 @@ func UpdateSubject(router *gin.RouterGroup) {
}
if _, err := m.UpdateName(f.SubjName); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
log.Errorf("subject: %s", err)
AbortSaveFailed(c)
return
}

View file

@ -304,6 +304,13 @@ func (m *Face) Create() error {
// Delete removes the face from the database.
func (m *Face) Delete() error {
// Remove face id from markers before deleting.
if err := Db().Model(&Marker{}).
Where("face_id = ?", m.ID).
UpdateColumns(Values{"face_id": "", "face_dist": -1}).Error; err != nil {
return err
}
return Db().Delete(m).Error
}

View file

@ -1,5 +1,7 @@
package entity
import "fmt"
// Faces represents a Face slice.
type Faces []Face
@ -20,3 +22,36 @@ func (f Faces) IDs() (ids []string) {
return ids
}
// Delete (soft) deletes all subjects.
func (f Faces) Delete() error {
for _, m := range f {
if err := m.Delete(); err != nil {
return err
}
}
return nil
}
// OrphanFaces returns unused faces.
func OrphanFaces() (Faces, error) {
orphans := Faces{}
err := Db().
Where(fmt.Sprintf("id NOT IN (SELECT DISTINCT face_id FROM %s)", Marker{}.TableName())).
Find(&orphans).Error
return orphans, err
}
// DeleteOrphanFaces finds and (soft) deletes all unused face clusters.
func DeleteOrphanFaces() (count int, err error) {
orphans, err := OrphanFaces()
if err != nil {
return 0, err
}
return len(orphans), orphans.Delete()
}

View file

@ -21,3 +21,13 @@ func TestFaces_IDs(t *testing.T) {
r := Faces{m, m1}.IDs()
assert.Equal(t, []string{"VF7ANLDET2BKZNT4VQWJMMC6HBEFDOG6", "VF7ANLDET2BKZNT4VQWJMMC6HBEFDOG7"}, r)
}
func TestDeleteOrphanFaces(t *testing.T) {
t.Run("Success", func(t *testing.T) {
if count, err := DeleteOrphanFaces(); err != nil {
t.Fatal(err)
} else {
t.Logf("deleted %d faces", count)
}
})
}

View file

@ -32,7 +32,7 @@ func (m *Keyword) Updates(values interface{}) error {
return UnscopedDb().Model(m).UpdateColumns(values).Error
}
// Updates a column in the database.
// Update a column in the database.
func (m *Keyword) Update(attr string, value interface{}) error {
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
}

View file

@ -39,6 +39,11 @@ var KeywordFixtures = KeywordMap{
Keyword: "kuh",
Skip: false,
},
"actress": {
ID: 1000004,
Keyword: "actress",
Skip: false,
},
}
// CreateKeywordFixtures inserts known entities into the database for testing.

View file

@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"math"
"strings"
"time"
"github.com/jinzhu/gorm"
@ -122,6 +121,29 @@ func (m *Marker) Update(attr string, value interface{}) error {
return UnscopedDb().Model(m).Update(attr, value).Error
}
// SetName changes the marker name.
func (m *Marker) SetName(name, src string) (changed bool, err error) {
if src == SrcAuto || SrcPriority[src] < SrcPriority[m.SubjSrc] {
return false, nil
}
name = txt.NormalizeName(name)
if name == "" {
return false, nil
}
if m.MarkerName == name {
// Name didn't change.
return false, nil
}
m.SubjSrc = src
m.MarkerName = name
return true, m.SyncSubject(true)
}
// SaveForm updates the entity using form data and stores it in the database.
func (m *Marker) SaveForm(f form.Marker) (changed bool, err error) {
if m.MarkerInvalid != f.MarkerInvalid {
@ -134,14 +156,9 @@ func (m *Marker) SaveForm(f form.Marker) (changed bool, err error) {
changed = true
}
if f.SubjSrc == SrcManual && strings.TrimSpace(f.MarkerName) != "" && f.MarkerName != m.MarkerName {
m.SubjSrc = SrcManual
m.MarkerName = txt.NormalizeName(f.MarkerName)
if err := m.SyncSubject(true); err != nil {
return changed, err
}
if nameChanged, err := m.SetName(f.MarkerName, f.SubjSrc); err != nil {
return changed, err
} else if nameChanged {
changed = true
}
@ -367,6 +384,7 @@ func (m *Marker) Subject() (subj *Subject) {
// Create subject?
if m.SubjSrc != SrcAuto && m.MarkerName != "" && m.SubjUID == "" {
if subj = NewSubject(m.MarkerName, SubjPerson, m.SubjSrc); subj == nil {
log.Errorf("marker: invalid subject %s", txt.Quote(m.MarkerName))
return nil
} else if subj = FirstOrCreateSubject(subj); subj == nil {
log.Debugf("marker: invalid subject %s", txt.Quote(m.MarkerName))
@ -391,6 +409,16 @@ func (m *Marker) ClearSubject(src string) error {
m.face = FindFace(m.FaceID)
}
defer func() {
// Find and (soft) delete unused subjects.
start := time.Now()
if count, err := DeleteOrphanPeople(); err != nil {
log.Errorf("marker: %s while removing unused subjects [%s]", err, time.Since(start))
} else if count > 0 {
log.Debugf("marker: removed %d people [%s]", count, time.Since(start))
}
}()
// Update index & resolve collisions.
if err := m.Updates(Values{"MarkerName": "", "FaceID": "", "FaceDist": -1.0, "SubjUID": "", "SubjSrc": src}); err != nil {
return err

File diff suppressed because one or more lines are too long

View file

@ -34,6 +34,31 @@ func TestNewMarker(t *testing.T) {
assert.Equal(t, MarkerLabel, m.MarkerType)
}
func TestMarker_SetName(t *testing.T) {
t.Run("InvalidName", func(t *testing.T) {
m := MarkerFixtures.Get("actress-a-1")
assert.IsType(t, Marker{}, m)
assert.Equal(t, "Actress A", m.MarkerName)
changed, err := m.SetName("", SrcManual)
if err != nil {
t.Fatal(err)
}
assert.False(t, changed)
assert.Equal(t, "Actress A", m.MarkerName)
changed, err = m.SetName("Foo Bar", SrcAuto)
if err != nil {
t.Fatal(err)
}
assert.False(t, changed)
assert.Equal(t, "Actress A", m.MarkerName)
})
}
func TestMarker_SaveForm(t *testing.T) {
t.Run("fa-ge add new name to marker then rename marker", func(t *testing.T) {
m := MarkerFixtures.Get("fa-gr-1")
@ -456,7 +481,7 @@ func TestMarker_GetFace(t *testing.T) {
if f := m.Face(); f == nil {
t.Fatal("return value must not be nil")
} else {
assert.Equal(t, "jqy3y652h8njw0sx", f.SubjUID)
assert.Equal(t, "VF7ANLDET2BKZNT4VQWJMMC6HBEFDOG6", f.ID)
}
})
t.Run("low quality marker", func(t *testing.T) {

View file

@ -418,6 +418,7 @@ func (m *Photo) IndexKeywords() error {
// Add title, description and other keywords
keywords = append(keywords, txt.Keywords(m.PhotoTitle)...)
keywords = append(keywords, txt.Keywords(m.PhotoDescription)...)
keywords = append(keywords, m.SubjectKeywords()...)
keywords = append(keywords, txt.Words(details.Keywords)...)
keywords = append(keywords, txt.Keywords(details.Subject)...)
keywords = append(keywords, txt.Keywords(details.Artist)...)

View file

@ -27,6 +27,10 @@ var PhotoKeywordFixtures = PhotoKeywordMap{
PhotoID: 1000023,
KeywordID: 1000003,
},
"7": {
PhotoID: 1000027,
KeywordID: 1000004,
},
}
// CreatePhotoKeywordFixtures inserts known entities into the database for testing.

View file

@ -244,3 +244,8 @@ func (m *Photo) SubjectNames() []string {
return nil
}
// SubjectKeywords returns keywords for all known subject names.
func (m *Photo) SubjectKeywords() []string {
return txt.Words(strings.Join(m.SubjectNames(), " "))
}

View file

@ -15,9 +15,6 @@ import (
var subjectMutex = sync.Mutex{}
// Subjects represents a list of subjects.
type Subjects []Subject
// Subject represents a named photo subject, typically a person.
type Subject struct {
SubjUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"UID" yaml:"UID"`
@ -100,6 +97,9 @@ func (m *Subject) Delete() error {
return nil
}
subjectMutex.Lock()
defer subjectMutex.Unlock()
log.Infof("subject: deleting %s %s", m.SubjType, txt.Quote(m.SubjName))
event.EntitiesDeleted("subjects", []string{m.SubjUID})
@ -111,6 +111,10 @@ func (m *Subject) Delete() error {
})
}
if err := Db().Model(&Face{}).Where("subj_uid = ?", m.SubjUID).Update("subj_uid", "").Error; err != nil {
return err
}
return Db().Delete(m).Error
}

View file

@ -32,7 +32,7 @@ func TestNewSubject(t *testing.T) {
}
func TestSubject_SetName(t *testing.T) {
t.Run("success", func(t *testing.T) {
t.Run("Success", func(t *testing.T) {
m := NewSubject("Jens Mander", SubjPerson, SrcAuto)
assert.Equal(t, "Jens Mander", m.SubjName)
@ -45,7 +45,7 @@ func TestSubject_SetName(t *testing.T) {
assert.Equal(t, "Foo McBar", m.SubjName)
assert.Equal(t, "foo-mcbar", m.SubjSlug)
})
t.Run("new name empty", func(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
m := NewSubject("Jens Mander", SubjPerson, SrcAuto)
assert.Equal(t, "Jens Mander", m.SubjName)

View file

@ -0,0 +1,42 @@
package entity
import (
"fmt"
)
// Subjects represents a list of subjects.
type Subjects []Subject
// Delete (soft) deletes all subjects.
func (m Subjects) Delete() error {
for _, subj := range m {
if err := subj.Delete(); err != nil {
return err
}
}
return nil
}
// OrphanPeople returns unused subjects.
func OrphanPeople() (Subjects, error) {
orphans := Subjects{}
err := Db().
Where("subj_type = ?", SubjPerson).
Where(fmt.Sprintf("subj_uid NOT IN (SELECT DISTINCT subj_uid FROM %s)", Marker{}.TableName())).
Find(&orphans).Error
return orphans, err
}
// DeleteOrphanPeople finds and (soft) deletes all unused people.
func DeleteOrphanPeople() (count int, err error) {
subj, err := OrphanPeople()
if err != nil {
return 0, err
}
return len(subj), subj.Delete()
}

View file

@ -0,0 +1,15 @@
package entity
import (
"testing"
)
func TestDeleteOrphanPeople(t *testing.T) {
t.Run("Success", func(t *testing.T) {
if count, err := DeleteOrphanPeople(); err != nil {
t.Fatal(err)
} else {
t.Logf("deleted %d faces", count)
}
})
}

View file

@ -33,6 +33,7 @@ const (
ErrZipFailed
ErrInvalidCredentials
ErrInvalidLink
ErrInvalidName
MsgChangesSaved
MsgAlbumCreated
@ -110,6 +111,7 @@ var Messages = MessageMap{
ErrZipFailed: gettext("Failed to create zip file"),
ErrInvalidCredentials: gettext("Invalid credentials"),
ErrInvalidLink: gettext("Invalid link"),
ErrInvalidName: gettext("Invalid name"),
// Info and confirmation messages:
MsgChangesSaved: gettext("Changes successfully saved"),

View file

@ -131,6 +131,22 @@ func (w *Faces) Start(opt FacesOptions) (err error) {
log.Debugf("faces: %d markers updated, %d faces recognized, %d unknown [%s]", matches.Updated, matches.Recognized, matches.Unknown, time.Since(start))
}
// Remove unused people.
start = time.Now()
if count, err := entity.DeleteOrphanPeople(); err != nil {
log.Errorf("faces: %s (remove people)", err)
} else if count > 0 {
log.Debugf("faces: removed %d people [%s]", count, time.Since(start))
}
// Remove unused face clusters.
start = time.Now()
if count, err := entity.DeleteOrphanFaces(); err != nil {
log.Errorf("faces: %s (remove clusters)", err)
} else if count > 0 {
log.Debugf("faces: removed %d clusters [%s]", count, time.Since(start))
}
return nil
}

View file

@ -117,5 +117,31 @@ func (w *Faces) Audit(fix bool) (err error) {
}
}
// Find and fix orphan faces clusters.
if orphans, err := entity.OrphanFaces(); err != nil {
log.Errorf("%s while finding orphan faces", err)
} else if l := len(orphans); l == 0 {
log.Infof("found no orphan faces clusters")
} else if !fix {
log.Infof("found %d orphan faces clusters", l)
} else if err := orphans.Delete(); err != nil {
log.Errorf("failed fixing %d orphan faces clusters: %s", l, err)
} else {
log.Infof("removed %d orphan faces clusters", l)
}
// Find and fix orphan people.
if orphans, err := entity.OrphanPeople(); err != nil {
log.Errorf("%s while finding orphan people", err)
} else if l := len(orphans); l == 0 {
log.Infof("found no orphan people")
} else if !fix {
log.Infof("found %d orphan people", l)
} else if err := orphans.Delete(); err != nil {
log.Errorf("failed fixing %d orphan people: %s", l, err)
} else {
log.Infof("removed %d orphan people", l)
}
return nil
}

View file

@ -79,7 +79,7 @@ func TestMatchFaceMarkers(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, int64(1), affected)
assert.Equal(t, int64(2), affected)
if m, err := MarkerByUID(faceFixtureId); err != nil {
t.Fatal(err)

View file

@ -93,7 +93,7 @@ func CreateMarkerSubjects() (affected int64, err error) {
if name == m.MarkerName && subj != nil {
// Do nothing.
} else if subj = entity.NewSubject(m.MarkerName, entity.SubjPerson, entity.SrcMarker); subj == nil {
log.Errorf("faces: subject should not be nil - bug?")
log.Errorf("faces: invalid subject %s", txt.Quote(m.MarkerName))
continue
} else if subj = entity.FirstOrCreateSubject(subj); subj == nil {
log.Errorf("faces: failed adding subject %s", txt.Quote(m.MarkerName))

View file

@ -235,3 +235,25 @@ func AnyInt(col, numbers, sep string, min, max int) (where string) {
return strings.Join(wheres, " OR ")
}
// OrLike returns a where condition and values for finding multiple terms combined with OR.
func OrLike(col, s string) (where string, values []interface{}) {
if col == "" || s == "" {
return "", []interface{}{}
}
s = strings.ReplaceAll(s, "*", "%")
s = strings.ReplaceAll(s, "%%", "%")
terms := strings.Split(s, txt.Or)
values = make([]interface{}, len(terms))
for i := range terms {
values[i] = terms[i]
}
like := fmt.Sprintf("%s LIKE ?", col)
where = like + strings.Repeat(" OR "+like, len(terms)-1)
return where, values
}

View file

@ -296,3 +296,24 @@ func TestAnyInt(t *testing.T) {
assert.Equal(t, "", where)
})
}
func TestOrLike(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
where, values := OrLike("k.keyword", "")
assert.Equal(t, "", where)
assert.Equal(t, []interface{}{}, values)
})
t.Run("OneTerm", func(t *testing.T) {
where, values := OrLike("k.keyword", "bar")
assert.Equal(t, "k.keyword LIKE ?", where)
assert.Equal(t, []interface{}{"bar"}, values)
})
t.Run("TwoTerms", func(t *testing.T) {
where, values := OrLike("k.keyword", "foo*%|bar")
assert.Equal(t, "k.keyword LIKE ? OR k.keyword LIKE ?", where)
assert.Equal(t, []interface{}{"foo%", "bar"}, values)
})
}

View file

@ -137,19 +137,13 @@ func Photos(f form.PhotoSearch) (results PhotoResults, count int, err error) {
// Clip to reasonable size and normalize operators.
f.Query = txt.NormalizeQuery(f.Query)
// Modify query if it contains subject names.
if f.Query != "" && f.Subject == "" {
if subj, names, remaining := SubjectUIDs(f.Query); len(subj) > 0 {
f.Subject = strings.Join(subj, txt.And)
log.Debugf("people: searching for %s", txt.Quote(txt.JoinNames(names, false)))
f.Query = remaining
}
}
// Set search filters based on search terms.
if terms := txt.SearchTerms(f.Query); f.Query != "" && len(terms) == 0 {
f.Name = fs.StripKnownExt(f.Query) + "*"
f.Query = ""
if f.Name == "" {
name := strings.Trim(fs.StripKnownExt(f.Query), "%*")
f.Name = fmt.Sprintf("%s*|%s*", name, strings.ToUpper(name))
f.Query = ""
}
} else if len(terms) > 0 {
switch {
case terms["faces"]:
@ -230,6 +224,9 @@ func Photos(f form.PhotoSearch) (results PhotoResults, count int, err error) {
s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 WHERE face_id IN (?))",
entity.Marker{}.TableName()), strings.Split(f, txt.Or))
}
} else if txt.New(f.Face) {
s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 AND m.marker_type = ? WHERE subj_uid IS NULL OR subj_uid = '')",
entity.Marker{}.TableName()), entity.MarkerFace)
} else if txt.No(f.Face) {
s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 AND m.marker_type = ? WHERE face_id IS NULL OR face_id = '')",
entity.Marker{}.TableName()), entity.MarkerFace)
@ -368,40 +365,38 @@ func Photos(f form.PhotoSearch) (results PhotoResults, count int, err error) {
if strings.HasSuffix(p, "/") {
s = s.Where("photos.photo_path = ?", p[:len(p)-1])
} else if strings.Contains(p, txt.Or) {
s = s.Where("photos.photo_path IN (?)", strings.Split(p, txt.Or))
} else {
s = s.Where("photos.photo_path LIKE ?", strings.ReplaceAll(p, "*", "%"))
where, values := OrLike("photos.photo_path", p)
s = s.Where(where, values...)
}
}
if strings.Contains(f.Name, txt.Or) {
s = s.Where("photos.photo_name IN (?)", strings.Split(f.Name, txt.Or))
} else if f.Name != "" {
s = s.Where("photos.photo_name LIKE ?", strings.ReplaceAll(fs.StripKnownExt(f.Name), "*", "%"))
// Filter by main file name.
if f.Name != "" {
where, values := OrLike("photos.photo_name", f.Name)
s = s.Where(where, values...)
}
if strings.Contains(f.Filename, txt.Or) {
s = s.Where("files.file_name IN (?)", strings.Split(f.Filename, txt.Or))
} else if f.Filename != "" {
s = s.Where("files.file_name LIKE ?", strings.ReplaceAll(f.Filename, "*", "%"))
// Filter by actual file name.
if f.Filename != "" {
where, values := OrLike("files.file_name", f.Filename)
s = s.Where(where, values...)
}
if strings.Contains(f.Original, txt.Or) {
s = s.Where("photos.original_name IN (?)", strings.Split(f.Original, txt.Or))
} else if f.Original != "" {
s = s.Where("photos.original_name LIKE ?", strings.ReplaceAll(f.Original, "*", "%"))
// Filter by original file name.
if f.Original != "" {
where, values := OrLike("photos.original_name", f.Original)
s = s.Where(where, values...)
}
if strings.Contains(f.Title, txt.Or) {
s = s.Where("photos.photo_title IN (?)", strings.Split(strings.ToLower(f.Title), txt.Or))
} else if f.Title != "" {
s = s.Where("photos.photo_title LIKE ?", strings.ReplaceAll(strings.ToLower(f.Title), "*", "%"))
// Filter by photo title.
if f.Title != "" {
where, values := OrLike("photos.photo_title", f.Title)
s = s.Where(where, values...)
}
if strings.Contains(f.Hash, txt.Or) {
s = s.Where("files.file_hash IN (?)", strings.Split(strings.ToLower(f.Hash), txt.Or))
} else if f.Hash != "" {
// Filter by file hash.
if f.Hash != "" {
s = s.Where("files.file_hash IN (?)", strings.Split(strings.ToLower(f.Hash), txt.Or))
}

View file

@ -41,19 +41,13 @@ func PhotosGeo(f form.PhotoSearchGeo) (results GeoResults, err error) {
// Clip to reasonable size and normalize operators.
f.Query = txt.NormalizeQuery(f.Query)
// Modify query if it contains subject names.
if f.Query != "" && f.Subject == "" {
if subj, names, remaining := SubjectUIDs(f.Query); len(subj) > 0 {
f.Subject = strings.Join(subj, txt.And)
log.Debugf("search: subject %s", txt.Quote(strings.Join(names, ", ")))
f.Query = remaining
}
}
// Set search filters based on search terms.
if terms := txt.SearchTerms(f.Query); f.Query != "" && len(terms) == 0 {
f.Name = fs.StripKnownExt(f.Query) + "*"
f.Query = ""
if f.Name == "" {
name := strings.Trim(fs.StripKnownExt(f.Query), "%*")
f.Name = fmt.Sprintf("%s*|%s*", name, strings.ToUpper(name))
f.Query = ""
}
} else if len(terms) > 0 {
switch {
case terms["faces"]:
@ -226,17 +220,16 @@ func PhotosGeo(f form.PhotoSearchGeo) (results GeoResults, err error) {
if strings.HasSuffix(p, "/") {
s = s.Where("photos.photo_path = ?", p[:len(p)-1])
} else if strings.Contains(p, txt.Or) {
s = s.Where("photos.photo_path IN (?)", strings.Split(p, txt.Or))
} else {
s = s.Where("photos.photo_path LIKE ?", strings.ReplaceAll(p, "*", "%"))
where, values := OrLike("photos.photo_path", p)
s = s.Where(where, values...)
}
}
if strings.Contains(f.Name, txt.Or) {
s = s.Where("photos.photo_name IN (?)", strings.Split(f.Name, txt.Or))
} else if f.Name != "" {
s = s.Where("photos.photo_name LIKE ?", strings.ReplaceAll(fs.StripKnownExt(f.Name), "*", "%"))
// Filter by main file name.
if f.Name != "" {
where, values := OrLike("photos.photo_name", f.Name)
s = s.Where(where, values...)
}
// Filter by status.

View file

@ -879,8 +879,7 @@ func TestPhotos(t *testing.T) {
t.Run("Subject", func(t *testing.T) {
var frm form.PhotoSearch
frm.Query = "John"
frm.Subject = ""
frm.Subject = "jqu0xs11qekk9jx8"
frm.Count = 10
frm.Offset = 0
@ -903,6 +902,21 @@ func TestPhotos(t *testing.T) {
}
}
})
t.Run("NewFaces", func(t *testing.T) {
var frm form.PhotoSearch
frm.Face = "new"
frm.Count = 10
frm.Offset = 0
photos, _, err := Photos(frm)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 1, len(photos))
})
t.Run("query: videos", func(t *testing.T) {
var frm form.PhotoSearch

View file

@ -44,6 +44,15 @@ func (m *Meta) Start(delay time.Duration) (err error) {
defer mutex.MetaWorker.Stop()
log.Debugf("metadata: running facial recognition")
// Run faces worker.
if w := photoprism.NewFaces(m.conf); w.Disabled() {
log.Debugf("metadata: skipping facial recognition")
} else if err := w.Start(photoprism.FacesOptions{}); err != nil {
log.Warn(err)
}
log.Debugf("metadata: starting routine check")
settings := m.conf.Settings()
@ -118,15 +127,6 @@ func (m *Meta) Start(delay time.Duration) (err error) {
log.Warn(err)
}
log.Debugf("metadata: running facial recognition")
// Run faces worker.
if w := photoprism.NewFaces(m.conf); w.Disabled() {
log.Debugf("metadata: skipping facial recognition")
} else if err := w.Start(photoprism.FacesOptions{}); err != nil {
log.Warn(err)
}
log.Debugf("metadata: updating photo counts")
// Update precalculated photo and file counts.

16
pkg/txt/search.go Normal file
View file

@ -0,0 +1,16 @@
package txt
// SearchTerms returns a bool map with all terms as key.
func SearchTerms(s string) map[string]bool {
result := make(map[string]bool)
if s == "" {
return result
}
for _, w := range UniqueKeywords(s) {
result[w] = true
}
return result
}

19
pkg/txt/search_test.go Normal file
View file

@ -0,0 +1,19 @@
package txt
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSearchTerms(t *testing.T) {
t.Run("Many", func(t *testing.T) {
result := SearchTerms("I'm a lazy-BRoWN fox! Yellow banana, apple; pan-pot b&w")
assert.Len(t, result, 6)
assert.Equal(t, map[string]bool{"apple": true, "banana": true, "fox": true, "lazy-brown": true, "pan-pot": true, "yellow": true}, result)
})
t.Run("Empty", func(t *testing.T) {
result := SearchTerms("")
assert.Len(t, result, 0)
})
}

View file

@ -17,6 +17,10 @@ func Bool(s string) bool {
// Yes returns true if a string represents "yes".
func Yes(s string) bool {
if s == "" {
return false
}
s = strings.ToLower(strings.TrimSpace(s))
return strings.IndexAny(s, "ytjposiд") == 0
@ -24,7 +28,22 @@ func Yes(s string) bool {
// No returns true if a string represents "no".
func No(s string) bool {
if s == "" {
return false
}
s = strings.ToLower(strings.TrimSpace(s))
return strings.IndexAny(s, "0nhufeн") == 0
}
// New returns true if a string represents "new".
func New(s string) bool {
if s == "" {
return false
}
s = strings.ToLower(strings.TrimSpace(s))
return s == "new"
}

View file

@ -125,3 +125,21 @@ func TestNo(t *testing.T) {
assert.Equal(t, false, No(""))
})
}
func TestNew(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, false, New(""))
})
t.Run("Uppercase", func(t *testing.T) {
assert.Equal(t, true, New("NEW"))
})
t.Run("Lowercase", func(t *testing.T) {
assert.Equal(t, true, New("new"))
})
t.Run("True", func(t *testing.T) {
assert.Equal(t, true, New("New"))
})
t.Run("False", func(t *testing.T) {
assert.Equal(t, false, New("non"))
})
}

View file

@ -202,24 +202,3 @@ func UniqueKeywords(s string) (results []string) {
func SortCaseInsensitive(words []string) {
sort.Slice(words, func(i, j int) bool { return strings.ToLower(words[i]) < strings.ToLower(words[j]) })
}
// SearchTerms returns a bool map with all terms as key.
func SearchTerms(s string) map[string]bool {
result := make(map[string]bool)
if s == "" {
return result
}
for _, w := range KeywordsRegexp.FindAllString(s, -1) {
w = strings.Trim(w, "- '")
if w == "" || len(w) < 3 && IsLatin(w) {
continue
}
result[w] = true
}
return result
}

View file

@ -239,15 +239,3 @@ func TestRemoveFromWords(t *testing.T) {
assert.Equal(t, []string{"apple", "brown", "jpg", "lazy"}, result)
})
}
func TestSearchTerms(t *testing.T) {
t.Run("Many", func(t *testing.T) {
result := SearchTerms("I'm a lazy-BRoWN fox! Yellow banana, apple; pan-pot b&w")
assert.Len(t, result, 7)
assert.Equal(t, map[string]bool{"I'm": true, "Yellow": true, "apple": true, "banana": true, "fox": true, "lazy-BRoWN": true, "pan-pot": true}, result)
})
t.Run("Empty", func(t *testing.T) {
result := SearchTerms("")
assert.Len(t, result, 0)
})
}