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:
parent
13d1abfb0d
commit
24eff21aa4
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
@ -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) {
|
||||
|
|
|
@ -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)...)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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(), " "))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
42
internal/entity/subjects.go
Normal file
42
internal/entity/subjects.go
Normal 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()
|
||||
}
|
15
internal/entity/subjects_test.go
Normal file
15
internal/entity/subjects_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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"),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
16
pkg/txt/search.go
Normal 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
19
pkg/txt/search_test.go
Normal 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)
|
||||
})
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue