mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-23 02:24:24 +00:00
489 lines
13 KiB
Go
489 lines
13 KiB
Go
package entity
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/jinzhu/gorm"
|
|
"github.com/jinzhu/inflection"
|
|
"github.com/ulule/deepcopier"
|
|
|
|
"github.com/photoprism/photoprism/internal/ai/classify"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
LabelUID = byte('l')
|
|
)
|
|
|
|
var labelMutex = sync.Mutex{}
|
|
var labelCategoriesMutex = sync.Mutex{}
|
|
|
|
// Labels is a convenience alias for slices of Label.
|
|
type Labels []Label
|
|
|
|
// Label represents a taxonomy entry used for categorizing photos, albums, and locations.
|
|
type Label struct {
|
|
ID uint `gorm:"primary_key" json:"ID" yaml:"-"`
|
|
LabelUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
|
|
LabelSlug string `gorm:"type:VARBINARY(160);unique_index;" json:"Slug" yaml:"-"`
|
|
CustomSlug string `gorm:"type:VARBINARY(160);index;" json:"CustomSlug" yaml:"-"`
|
|
LabelName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name"`
|
|
LabelFavorite bool `gorm:"default:0;" json:"Favorite" yaml:"Favorite,omitempty"`
|
|
LabelPriority int `gorm:"default:0;" json:"Priority" yaml:"Priority,omitempty"`
|
|
LabelNSFW bool `gorm:"column:label_nsfw;default:0;" json:"NSFW,omitempty" yaml:"NSFW,omitempty"`
|
|
LabelDescription string `gorm:"type:VARCHAR(2048);" json:"Description" yaml:"Description,omitempty"`
|
|
LabelNotes string `gorm:"type:VARCHAR(1024);" json:"Notes" yaml:"Notes,omitempty"`
|
|
LabelCategories []*Label `gorm:"many2many:categories;association_jointable_foreignkey:category_id" json:"-" yaml:"-"`
|
|
PhotoCount int `gorm:"default:1" 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:"-"`
|
|
PublishedAt *time.Time `sql:"index" json:"PublishedAt,omitempty" yaml:"PublishedAt,omitempty"`
|
|
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
|
|
New bool `gorm:"-" json:"-" yaml:"-"`
|
|
MaxHomophone string `gorm:"type:VARCHAR(1);" yaml:"-"`
|
|
}
|
|
|
|
// TableName returns the entity table name.
|
|
func (Label) TableName() string {
|
|
return "labels"
|
|
}
|
|
|
|
// AfterUpdate flushes the label cache when a label is updated.
|
|
func (m *Label) AfterUpdate(tx *gorm.DB) (err error) {
|
|
FlushLabelCache()
|
|
return
|
|
}
|
|
|
|
// AfterDelete flushes the label cache when a label is deleted.
|
|
func (m *Label) AfterDelete(tx *gorm.DB) (err error) {
|
|
FlushLabelCache()
|
|
return
|
|
}
|
|
|
|
// AfterCreate sets the New column used for database callback
|
|
func (m *Label) AfterCreate(scope *gorm.Scope) error {
|
|
m.New = true
|
|
FlushLabelCache()
|
|
return nil
|
|
}
|
|
|
|
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
|
|
func (m *Label) BeforeCreate(scope *gorm.Scope) error {
|
|
if rnd.IsUnique(m.LabelUID, LabelUID) {
|
|
return nil
|
|
}
|
|
|
|
return scope.SetColumn("LabelUID", rnd.GenerateUID(LabelUID))
|
|
}
|
|
|
|
// NewLabel constructs a label entity from a name and priority, normalizing title/slug fields.
|
|
func NewLabel(name string, priority int) *Label {
|
|
labelName := txt.Clip(name, txt.ClipDefault)
|
|
|
|
if labelName == "" {
|
|
labelName = "Unknown"
|
|
}
|
|
|
|
labelName = txt.Title(labelName)
|
|
labelSlug := txt.Slug(labelName)
|
|
|
|
result := &Label{
|
|
LabelSlug: labelSlug,
|
|
CustomSlug: labelSlug,
|
|
LabelName: txt.Clip(labelName, txt.ClipName),
|
|
LabelPriority: priority,
|
|
PhotoCount: 1,
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Save persists label changes while holding the global label mutex.
|
|
func (m *Label) Save() error {
|
|
labelMutex.Lock()
|
|
defer labelMutex.Unlock()
|
|
|
|
return Db().Save(m).Error
|
|
}
|
|
|
|
// SaveForm copies validated form data into the label and persists it.
|
|
func (m *Label) SaveForm(f *form.Label) error {
|
|
if f == nil {
|
|
return fmt.Errorf("form is nil")
|
|
} else if f.LabelName == "" || txt.Slug(f.LabelName) == "" {
|
|
return ErrInvalidName
|
|
}
|
|
|
|
labelMutex.Lock()
|
|
defer labelMutex.Unlock()
|
|
|
|
if err := deepcopier.Copy(m).From(f); err != nil {
|
|
return err
|
|
}
|
|
|
|
if m.SetName(f.LabelName) {
|
|
return Db().Save(m).Error
|
|
} else {
|
|
return ErrInvalidName
|
|
}
|
|
}
|
|
|
|
// Create inserts the label into the database while holding the global label mutex.
|
|
func (m *Label) Create() error {
|
|
labelMutex.Lock()
|
|
defer labelMutex.Unlock()
|
|
|
|
return UnscopedDb().Create(m).Error
|
|
}
|
|
|
|
// Delete removes the label from the database.
|
|
func (m *Label) Delete() error {
|
|
Db().Where("label_id = ? OR category_id = ?", m.ID, m.ID).Delete(&Category{})
|
|
Db().Where("label_id = ?", m.ID).Delete(&PhotoLabel{})
|
|
FlushLabelCache()
|
|
return Db().Delete(m).Error
|
|
}
|
|
|
|
// Deleted returns true if the label is deleted.
|
|
func (m *Label) Deleted() bool {
|
|
if m.DeletedAt == nil {
|
|
return false
|
|
}
|
|
|
|
return !m.DeletedAt.IsZero()
|
|
}
|
|
|
|
// Restore restores the label in the database.
|
|
func (m *Label) Restore() error {
|
|
if m.Deleted() {
|
|
return UnscopedDb().Model(m).Update("DeletedAt", nil).Error
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// HasID tests if the entity has an ID and a valid UID.
|
|
func (m *Label) HasID() bool {
|
|
if m == nil {
|
|
return false
|
|
}
|
|
|
|
return m.ID > 0 && m.HasUID()
|
|
}
|
|
|
|
// HasUID tests if the entity has a valid UID.
|
|
func (m *Label) HasUID() bool {
|
|
if m == nil {
|
|
return false
|
|
}
|
|
|
|
return rnd.IsUID(m.LabelUID, LabelUID)
|
|
}
|
|
|
|
// Skip tests if the entity has invalid IDs or has been deleted and therefore should not be assigned.
|
|
func (m *Label) Skip() bool {
|
|
if m == nil {
|
|
return true
|
|
} else if !m.HasID() {
|
|
return true
|
|
} else if m.Deleted() {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Update a label property in the database.
|
|
func (m *Label) Update(attr string, value interface{}) error {
|
|
if m == nil {
|
|
return errors.New("label must not be nil - you may have found a bug")
|
|
} else if !m.HasID() {
|
|
return errors.New("label ID must not be empty - you may have found a bug")
|
|
}
|
|
|
|
return UnscopedDb().Model(m).Update(attr, value).Error
|
|
}
|
|
|
|
// Updates multiple columns in the database.
|
|
func (m *Label) Updates(values interface{}) error {
|
|
if values == nil {
|
|
return nil
|
|
} else if m == nil {
|
|
return errors.New("label must not be nil - you may have found a bug")
|
|
} else if !m.HasID() {
|
|
return errors.New("label ID must not be empty - you may have found a bug")
|
|
}
|
|
|
|
return UnscopedDb().Model(m).Updates(values).Error
|
|
}
|
|
|
|
// isASCII checks if there are any runes that exceed the ASCII codeset.
|
|
func isASCII(s string) bool {
|
|
for _, r := range s {
|
|
if r > unicode.MaxASCII {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// FirstOrCreateLabel reuses an existing label matched by slug/custom slug or creates and returns a new one; nil signals lookup/create failure.
|
|
func FirstOrCreateLabel(m *Label) *Label {
|
|
if m.LabelSlug == "" && m.CustomSlug == "" {
|
|
return nil
|
|
}
|
|
|
|
var labels []Label
|
|
slugLike := m.LabelSlug + "-c-_"
|
|
|
|
nameASCII := isASCII(m.LabelName)
|
|
|
|
if err := UnscopedDb().
|
|
Where("((custom_slug <> '' AND custom_slug = ?) OR (label_slug <> '' AND (label_slug = ? OR label_slug like ?)))", m.CustomSlug, m.LabelSlug, slugLike).
|
|
Find(&labels).Error; err == nil {
|
|
slugChar := byte('a')
|
|
for _, l := range labels {
|
|
if strings.EqualFold(clean.LabelName(l.LabelName), clean.LabelName(m.LabelName)) || (nameASCII && isASCII(l.LabelName)) {
|
|
return &l
|
|
} else {
|
|
sl := len(l.LabelSlug)
|
|
if sl > 5 {
|
|
if l.LabelSlug[sl-4:sl-1] == "-c-" && l.LabelSlug[sl-1] >= byte(slugChar) {
|
|
slugChar = l.LabelSlug[len(l.LabelSlug)-1] + 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(labels) > 0 {
|
|
if slugChar > byte('z') {
|
|
log.Errorf("label: %s (find or create %s)", fmt.Errorf("too many homophones for slug"), m.LabelSlug)
|
|
return nil
|
|
} else {
|
|
if err := UnscopedDb().Model(&Label{}).Where("label_slug = ? or label_slug like ?", m.LabelSlug, slugLike).Update("max_homophone", string(slugChar)).Error; err != nil {
|
|
log.Errorf("label: %s (find or create %s)", err, m.LabelSlug)
|
|
return nil
|
|
}
|
|
m.LabelSlug = fmt.Sprintf("%s-c-%s", txt.Clip(m.LabelSlug, txt.ClipSlug-4), string(slugChar))
|
|
m.CustomSlug = m.LabelSlug
|
|
m.MaxHomophone = string(slugChar)
|
|
}
|
|
}
|
|
}
|
|
if createErr := m.Create(); createErr == nil {
|
|
if m.LabelPriority >= 0 {
|
|
event.EntitiesCreated("labels", []*Label{m})
|
|
|
|
event.Publish("count.labels", event.Data{
|
|
"count": 1,
|
|
})
|
|
}
|
|
|
|
return m
|
|
} else if err := UnscopedDb().
|
|
Where("((custom_slug <> '' AND custom_slug = ?) OR (label_slug <> '' AND (label_slug = ? OR label_slug like ?)))", m.CustomSlug, m.LabelSlug, slugLike).
|
|
Find(&labels).Error; err == nil {
|
|
for _, l := range labels {
|
|
if strings.EqualFold(clean.LabelName(l.LabelName), clean.LabelName(m.LabelName)) || (nameASCII && isASCII(l.LabelName)) {
|
|
return &l
|
|
}
|
|
}
|
|
log.Errorf("label: %s (find or create %s)", fmt.Errorf("record not found"), m.LabelSlug)
|
|
} else {
|
|
log.Errorf("label: %s (find or create %s)", createErr, m.LabelSlug)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetName changes the label name.
|
|
func (m *Label) SetName(name string) bool {
|
|
labelName := txt.Clip(clean.NameCapitalized(name), txt.ClipName)
|
|
|
|
if labelName == "" {
|
|
return false
|
|
}
|
|
|
|
labelSlug := txt.Slug(labelName)
|
|
|
|
if labelSlug == "" {
|
|
return false
|
|
}
|
|
|
|
m.LabelName = labelName
|
|
m.CustomSlug = labelSlug
|
|
|
|
if m.LabelSlug == "" {
|
|
m.LabelSlug = labelSlug
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// InvalidName checks if the label name is invalid.
|
|
func (m *Label) InvalidName() bool {
|
|
labelName := txt.Clip(clean.NameCapitalized(m.LabelName), txt.ClipName)
|
|
|
|
if labelName == "" {
|
|
return true
|
|
}
|
|
|
|
labelSlug := txt.Slug(labelName)
|
|
|
|
if labelSlug == "" {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// GetSlug returns the label slug.
|
|
func (m *Label) GetSlug() string {
|
|
if m.CustomSlug != "" {
|
|
return m.CustomSlug
|
|
} else if m.LabelSlug != "" {
|
|
return m.LabelSlug
|
|
}
|
|
|
|
return txt.Slug(m.LabelName)
|
|
}
|
|
|
|
// UpdateClassify updates a label if necessary
|
|
func (m *Label) UpdateClassify(label classify.Label) error {
|
|
save := false
|
|
db := Db()
|
|
|
|
if m.LabelPriority != label.Priority {
|
|
m.LabelPriority = label.Priority
|
|
save = true
|
|
}
|
|
|
|
if m.CustomSlug == "" {
|
|
m.CustomSlug = m.LabelSlug
|
|
save = true
|
|
} else if m.LabelSlug == "" {
|
|
m.LabelSlug = m.CustomSlug
|
|
save = true
|
|
}
|
|
|
|
if m.CustomSlug == m.LabelSlug && label.Title() != m.LabelName {
|
|
if m.SetName(label.Title()) {
|
|
save = true
|
|
} else {
|
|
return ErrInvalidName
|
|
}
|
|
}
|
|
|
|
// Save label.
|
|
if save {
|
|
if err := db.Save(m).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Update label categories.
|
|
if len(label.Categories) > 0 {
|
|
labelCategoriesMutex.Lock()
|
|
defer labelCategoriesMutex.Unlock()
|
|
|
|
for _, category := range label.Categories {
|
|
sn := FirstOrCreateLabel(NewLabel(txt.Title(category), -3))
|
|
|
|
if sn == nil {
|
|
continue
|
|
}
|
|
|
|
if sn.Skip() {
|
|
continue
|
|
}
|
|
|
|
if err := db.Model(m).Association("LabelCategories").Append(sn).Error; err != nil {
|
|
log.Debugf("index: failed saving label category %s (%s)", clean.Log(category), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Links returns all share links for this entity.
|
|
func (m *Label) Links() Links {
|
|
return FindLinks("", m.LabelUID)
|
|
}
|
|
|
|
// FindLabels finds all labels that match a name (ie. handles homophones)
|
|
func FindLabels(names string, sep string, unscoped bool) (labels []Label, err error) {
|
|
if names == "" {
|
|
return nil, fmt.Errorf("no names provided")
|
|
}
|
|
|
|
var db *gorm.DB
|
|
if unscoped {
|
|
db = UnscopedDb()
|
|
} else {
|
|
db = Db()
|
|
}
|
|
|
|
// Generate Where and Values
|
|
var wheres []string
|
|
var ins []string
|
|
var values []interface{}
|
|
for _, w := range strings.Split(names, sep) {
|
|
w = txt.Slug(w)
|
|
if !slices.Contains(ins, w) {
|
|
ins = append(ins, w)
|
|
values = append(values, fmt.Sprintf("%s-c-_", w))
|
|
wheres = append(wheres, "label_slug like ?")
|
|
}
|
|
if !txt.ContainsASCIILetters(w) {
|
|
continue
|
|
}
|
|
singular := inflection.Singular(w)
|
|
|
|
if singular != w {
|
|
singular = txt.Slug(singular)
|
|
if !slices.Contains(ins, singular) {
|
|
ins = append(ins, singular)
|
|
values = append(values, fmt.Sprintf("%s-c-_", singular))
|
|
wheres = append(wheres, "label_slug like ?")
|
|
}
|
|
}
|
|
}
|
|
wheres = append(wheres, "label_slug IN (?) OR custom_slug IN (?)")
|
|
values = append(values, ins)
|
|
values = append(values, ins)
|
|
|
|
where := strings.Join(wheres, " OR ")
|
|
|
|
var ls []Label
|
|
if err := db.Where(where, values...).Find(&ls).Error; err != nil {
|
|
return labels, err
|
|
}
|
|
|
|
nameSlice := strings.Split(names, sep)
|
|
for _, l := range ls {
|
|
if l.MaxHomophone == "" {
|
|
labels = append(labels, l)
|
|
} else {
|
|
for _, w := range nameSlice {
|
|
w = strings.TrimSpace(w)
|
|
// If the cleansed Name matches or it's an exact match between the name and the Slug, append.
|
|
if strings.EqualFold(clean.LabelName(l.LabelName), clean.LabelName(w)) || l.LabelSlug == w || l.CustomSlug == w {
|
|
labels = append(labels, l)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return labels, nil
|
|
}
|