photoprism/internal/entity/subject.go
Michael Mayer 1507525ba4 People: Fix merging and renaming in connection with deleted names #3414
Signed-off-by: Michael Mayer <michael@photoprism.app>
2023-08-21 15:04:14 +02:00

532 lines
14 KiB
Go

package entity
import (
"fmt"
"strings"
"sync"
"time"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
)
var subjectMutex = sync.Mutex{}
// 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"`
SubjType string `gorm:"type:VARBINARY(8);default:'';" json:"Type,omitempty" yaml:"Type,omitempty"`
SubjSrc string `gorm:"type:VARBINARY(8);default:'';" json:"Src,omitempty" yaml:"Src,omitempty"`
SubjSlug string `gorm:"type:VARBINARY(160);index;default:'';" json:"Slug" yaml:"-"`
SubjName string `gorm:"size:160;unique_index;default:'';" json:"Name" yaml:"Name"`
SubjAlias string `gorm:"size:160;default:'';" json:"Alias" yaml:"Alias"`
SubjAbout string `gorm:"size:512;" json:"About" yaml:"About,omitempty"`
SubjBio string `gorm:"size:2048;" json:"Bio" yaml:"Bio,omitempty"`
SubjNotes string `gorm:"size:1024;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
SubjFavorite bool `gorm:"default:false;" json:"Favorite" yaml:"Favorite,omitempty"`
SubjHidden bool `gorm:"default:false;" json:"Hidden" yaml:"Hidden,omitempty"`
SubjPrivate bool `gorm:"default:false;" json:"Private" yaml:"Private,omitempty"`
SubjExcluded bool `gorm:"default:false;" json:"Excluded" yaml:"Excluded,omitempty"`
FileCount int `gorm:"default:0;" json:"FileCount" yaml:"-"`
PhotoCount int `gorm:"default:0;" json:"PhotoCount" yaml:"-"`
Thumb string `gorm:"type:VARBINARY(128);index;default:'';" json:"Thumb" yaml:"Thumb,omitempty"`
ThumbSrc string `gorm:"type:VARBINARY(8);default:'';" json:"ThumbSrc,omitempty" yaml:"ThumbSrc,omitempty"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
}
// TableName returns the entity table name.
func (Subject) TableName() string {
return "subjects"
}
// BeforeCreate creates a random uid if needed before inserting a new row to the database.
func (m *Subject) BeforeCreate(scope *gorm.Scope) error {
if rnd.IsUnique(m.SubjUID, 'j') {
return nil
}
return scope.SetColumn("SubjUID", rnd.GenerateUID('j'))
}
// AfterSave is a hook that updates the name cache after saving.
func (m *Subject) AfterSave() (err error) {
SubjNames.Set(m.SubjUID, m.SubjName)
return
}
// AfterFind is a hook that updates the name cache after querying.
func (m *Subject) AfterFind() (err error) {
SubjNames.Set(m.SubjUID, m.SubjName)
return
}
// NewSubject returns a new entity.
func NewSubject(name, subjType, subjSrc string) *Subject {
// Name is required.
if strings.TrimSpace(name) == "" {
return nil
}
if subjType == "" {
subjType = SubjPerson
}
result := &Subject{
SubjType: subjType,
SubjSrc: subjSrc,
FileCount: 1,
}
if err := result.SetName(name); err != nil {
log.Errorf("subject: %s", err)
}
return result
}
// Save updates the record in the database or inserts a new record if it does not already exist.
func (m *Subject) Save() error {
subjectMutex.Lock()
defer subjectMutex.Unlock()
return Db().Save(m).Error
}
// Create inserts the entity to the database.
func (m *Subject) Create() error {
subjectMutex.Lock()
defer subjectMutex.Unlock()
return Db().Create(m).Error
}
// Delete marks the entity as deleted in the database.
func (m *Subject) Delete() error {
if m.Deleted() {
return nil
}
subjectMutex.Lock()
defer subjectMutex.Unlock()
event.EntitiesDeleted("subjects", []string{m.SubjUID})
if m.IsPerson() {
event.EntitiesDeleted("people", []string{m.SubjUID})
event.Publish("count.people", event.Data{
"count": -1,
})
}
if err := Db().Model(&Face{}).Where("subj_uid = ?", m.SubjUID).Update("subj_uid", "").Error; err != nil {
return err
}
log.Infof("subject: flagged %s %s as missing", TypeString(m.SubjType), clean.Log(m.SubjName))
return Db().Delete(m).Error
}
// DeletePermanently permanently removes a subject from the index after is has been soft deleted.
func (m *Subject) DeletePermanently() error {
if !m.Deleted() {
return nil
}
subjectMutex.Lock()
defer subjectMutex.Unlock()
SubjNames.Unset(m.SubjUID)
return UnscopedDb().Delete(m).Error
}
// AfterDelete resets file and photo counters when the entity was deleted.
func (m *Subject) AfterDelete(tx *gorm.DB) (err error) {
tx.Model(m).Updates(Values{
"FileCount": 0,
"PhotoCount": 0,
})
SubjNames.Unset(m.SubjUID)
return
}
// Deleted returns true if the entity is deleted.
func (m *Subject) Deleted() bool {
if m.DeletedAt == nil {
return false
}
return !m.DeletedAt.IsZero()
}
// Restore restores the entity in the database.
func (m *Subject) Restore() error {
if m.Deleted() {
m.DeletedAt = nil
log.Infof("subject: restoring %s %s", TypeString(m.SubjType), clean.Log(m.SubjName))
event.EntitiesCreated("subjects", []*Subject{m})
if m.IsPerson() {
event.EntitiesCreated("people", []*Person{m.Person()})
event.Publish("count.people", event.Data{
"count": 1,
})
}
return UnscopedDb().Model(m).UpdateColumn("DeletedAt", nil).Error
}
return nil
}
// Update updates an entity value in the database.
func (m *Subject) Update(attr string, value interface{}) error {
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
}
// Updates multiple values in the database.
func (m *Subject) Updates(values interface{}) error {
return UnscopedDb().Model(m).Updates(values).Error
}
// FirstOrCreateSubject returns the existing entity, inserts a new entity or nil in case of errors.
func FirstOrCreateSubject(m *Subject) *Subject {
if m == nil {
return nil
} else if m.SubjName == "" {
return nil
}
if found := FindSubjectByName(m.SubjName, true); found != nil {
return found
} else if err := m.Create(); err == nil {
log.Infof("subject: added %s %s", TypeString(m.SubjType), clean.Log(m.SubjName))
event.EntitiesCreated("subjects", []*Subject{m})
if m.IsPerson() {
event.EntitiesCreated("people", []*Person{m.Person()})
event.Publish("count.people", event.Data{
"count": 1,
})
}
return m
} else if found = FindSubjectByName(m.SubjName, true); found != nil {
return found
} else {
log.Errorf("subject: failed adding %s (%s)", clean.Log(m.SubjName), err)
}
return nil
}
// FindSubject returns an existing entity if exists.
func FindSubject(uid string) *Subject {
if uid == "" {
return nil
}
result := Subject{}
if err := UnscopedDb().Where("subj_uid = ?", uid).First(&result).Error; err != nil {
return nil
}
return &result
}
// FindSubjectByName find an existing subject by name.
func FindSubjectByName(name string, restore bool) *Subject {
name = clean.Name(name)
if name == "" {
return nil
}
result := Subject{}
// Fetch existing record by uid, if possible
if uid := SubjNames.Key(name); uid == "" {
} else if found := FindSubject(uid); found != nil {
result = *found
} else {
log.Debugf("subject: cannot find record for uid %s", clean.Log(uid))
}
// Search existing record by name, otherwise.
if result.SubjUID != "" {
} else if err := UnscopedDb().Where("subj_name LIKE ?", name).First(&result).Error; err != nil {
log.Debugf("subject: %s does not exist yet", clean.Log(name))
return nil
}
// Restore record if flagged as deleted.
if result.Deleted() && restore {
if err := result.Restore(); err == nil {
log.Debugf("subject: restored %s", clean.Log(result.SubjName))
return &result
} else {
log.Errorf("subject: failed to restore %s (%s)", clean.Log(result.SubjName), err)
return nil
}
}
return &result
}
// IsPerson tests if the subject is a person.
func (m *Subject) IsPerson() bool {
return m.SubjType == SubjPerson
}
// Person creates and returns a Person based on this subject.
func (m *Subject) Person() *Person {
return NewPerson(*m)
}
// SetName changes the subject's name.
func (m *Subject) SetName(name string) error {
name = clean.Name(name)
if name == m.SubjName {
// Nothing to do.
return nil
} else if name == "" {
return fmt.Errorf("name must not be empty")
}
m.SubjName = name
m.SubjSlug = txt.Slug(name)
return nil
}
// Visible tests if the subject is generally visible and not hidden in any way.
func (m *Subject) Visible() bool {
return m.DeletedAt == nil && !m.SubjHidden && !m.SubjExcluded && !m.SubjPrivate
}
// SaveForm updates the subject from form values.
func (m *Subject) SaveForm(f form.Subject) (changed bool, err error) {
if m.SubjUID == "" {
return false, fmt.Errorf("subject has no uid")
}
// Change name?
if name := clean.Name(f.SubjName); name != "" && name != m.SubjName {
existing, err := m.UpdateName(name)
if existing.SubjUID != m.SubjUID || err != nil {
return err != nil, err
}
changed = true
}
// Change favorite status?
if m.SubjFavorite != f.SubjFavorite {
m.SubjFavorite = f.SubjFavorite
changed = true
}
// Change visibility?
if m.SubjHidden != f.SubjHidden || m.SubjPrivate != f.SubjPrivate || m.SubjExcluded != f.SubjExcluded {
m.SubjHidden = f.SubjHidden
m.SubjPrivate = f.SubjPrivate
m.SubjExcluded = f.SubjExcluded
// Update counter.
if !m.IsPerson() {
// Ignore.
} else if m.Visible() {
event.Publish("count.people", event.Data{
"count": 1,
})
} else {
event.Publish("count.people", event.Data{
"count": -1,
})
}
changed = true
}
// Update index?
if changed {
values := Values{
"SubjFavorite": m.SubjFavorite,
"SubjHidden": m.SubjHidden,
"SubjPrivate": m.SubjPrivate,
"SubjExcluded": m.SubjExcluded,
}
if err := m.Updates(values); err == nil {
event.EntitiesUpdated("subjects", []*Subject{m})
if m.IsPerson() {
event.EntitiesUpdated("people", []*Person{m.Person()})
}
return true, nil
} else {
return false, err
}
}
return false, nil
}
// UpdateName changes and saves the subject's name in the index.
func (m *Subject) UpdateName(name string) (*Subject, error) {
// Make sure the subject has a name and UID.
if m.SubjName == "" {
return m, fmt.Errorf("subject name is empty")
} else if m.SubjUID == "" {
return m, fmt.Errorf("subject has no uid")
}
// Validate new subject name.
name = clean.Name(name)
if name == m.SubjName {
// Nothing to do.
return m, nil
} else if name == "" {
return m, fmt.Errorf("new subject name is empty")
}
// Check if subject already exists.
if existing := FindSubjectByName(name, false); existing == nil {
// Do nothing.
} else if existing.Deleted() {
// see https://github.com/photoprism/photoprism/issues/3414
if err := existing.DeletePermanently(); err != nil {
return m, err
}
} else if existing.SubjUID != m.SubjUID {
return existing, m.MergeWith(existing)
}
// Update subject record.
if err := m.SetName(name); err != nil {
return m, err
} else if err = m.Updates(Values{"SubjName": m.SubjName, "SubjSlug": m.SubjSlug}); err != nil {
return m, err
} else {
SubjNames.Set(m.SubjUID, m.SubjName)
}
// Log result.
log.Infof("subject: renamed %s to %s", TypeString(m.SubjType), clean.Log(m.SubjName))
event.EntitiesUpdated("subjects", []*Subject{m})
if m.IsPerson() {
event.EntitiesUpdated("people", []*Person{m.Person()})
}
return m, m.UpdateMarkerNames()
}
// UpdateMarkerNames updates related marker names.
func (m *Subject) UpdateMarkerNames() error {
// Make sure the subject has a name and UID.
if m.SubjName == "" {
return fmt.Errorf("subject name is empty")
} else if m.SubjUID == "" {
return fmt.Errorf("subject has no uid")
}
// Update markers table to match current subject name.
if err := UnscopedDb().Model(&Marker{}).
Where("subj_uid = ? AND subj_src <> ?", m.SubjUID, SrcAuto).
Where("marker_name <> ?", m.SubjName).
UpdateColumn("marker_name", m.SubjName).Error; err != nil {
return err
}
return m.RefreshPhotos()
}
// RefreshPhotos flags related photos for metadata maintenance.
func (m *Subject) RefreshPhotos() error {
if m.SubjUID == "" {
return fmt.Errorf("empty subject uid")
}
var err error
switch DbDialect() {
case MySQL:
update := fmt.Sprintf(`UPDATE photos p JOIN files f ON f.photo_id = p.id JOIN %s m ON m.file_uid = f.file_uid
SET p.checked_at = NULL WHERE m.subj_uid = ?`, Marker{}.TableName())
err = UnscopedDb().Exec(update, m.SubjUID).Error
default:
update := fmt.Sprintf(`UPDATE photos SET checked_at = NULL WHERE id IN (SELECT f.photo_id FROM files f
JOIN %s m ON m.file_uid = f.file_uid WHERE m.subj_uid = ?)`, Marker{}.TableName())
err = UnscopedDb().Exec(update, m.SubjUID).Error
}
return err
}
// MergeWith merges this subject with another subject and then deletes it.
func (m *Subject) MergeWith(other *Subject) error {
if other == nil {
return fmt.Errorf("subject cannot be merged if other subject is nil")
} else if other.SubjUID == "" {
return fmt.Errorf("subject cannot be merged if other subject uid is missing")
} else if m.SubjUID == "" {
return fmt.Errorf("subject cannot be merged if uid is missing")
} else if other.Deleted() {
return fmt.Errorf("subject cannot be merged with deleted subject")
}
// Update markers and faces with new SubjUID.
if err := UnscopedDb().Model(&Marker{}).
Where("subj_uid = ?", m.SubjUID).
UpdateColumn("subj_uid", other.SubjUID).Error; err != nil {
return err
} else if err = UnscopedDb().Model(&Face{}).
Where("subj_uid = ?", m.SubjUID).
UpdateColumn("subj_uid", other.SubjUID).Error; err != nil {
return err
} else if err = other.UpdateMarkerNames(); err != nil {
return err
}
// Updated subject entity values.
updates := Values{
"FileCount": other.FileCount + m.FileCount,
"PhotoCount": other.PhotoCount + m.PhotoCount,
}
// Use existing thumbnail image?
if other.ThumbSrc == SrcAuto && other.Thumb == "" && m.Thumb != "" {
updates["Thumb"] = m.Thumb
updates["ThumbSrc"] = m.ThumbSrc
}
// Update subject entity.
if err := UnscopedDb().Model(other).Updates(updates).Error; err != nil {
return err
}
return m.Delete()
}
// Links returns all share links for this entity.
func (m *Subject) Links() Links {
return FindLinks("", m.SubjUID)
}