This commit is contained in:
Keith Martin 2026-01-21 18:46:13 +00:00 committed by GitHub
commit 89421380e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1778 additions and 73 deletions

View file

@ -229,3 +229,48 @@ test.meta("testID", "labels-005").meta({ mode: "public" })("Common: Test mark la
.click(Selector("button.action-confirm"));
await label.checkHoverActionState("uid", FirstLabelUid, "favorite", true);
});
test.meta("testID", "labels-006").meta({ mode: "public"})("Common: Test homophone label", async(t) => {
await menu.openPage("browse");
await t.click(toolbar.cardsViewAction);
const FirstPhotoUid = await photo.getNthPhotoUid("image", 0);
await page.clickCardTitleOfUID(FirstPhotoUid);
await t
.click(photoedit.labelsTab)
.typeText(photoedit.inputLabelName, "上海")
.click(Selector(photoedit.addLabel))
.typeText(photoedit.inputLabelName, "伤害")
.click(Selector(photoedit.addLabel))
.click(photoedit.detailsTab);
await t.click(photoedit.labelsTab);
await t.expect(Selector('.text-start').withText("上海").visible).ok();
await t.expect(Selector('.text-start').withText("伤害").visible).ok();
await t.click(photoedit.dialogClose);
await menu.openPage("labels");
await toolbar.search("上海");
const LabelTest1 = await label.getNthLabeltUid(0);
await label.openLabelWithUid(LabelTest1);
const LabelPhotoTest1 = await photo.getNthPhotoUid("image", 0);
await page.clickCardTitleOfUID(LabelPhotoTest1);
await t.click(photoedit.labelsTab);
await t.expect(Selector('.text-start').withText("上海").visible).ok();
await t.expect(Selector('.text-start').withText("伤害").visible).ok();
await t.click(photoedit.dialogClose);
await menu.openPage("labels");
await toolbar.search("伤害");
const LabelTest2 = await label.getNthLabeltUid(0);
await label.openLabelWithUid(LabelTest2);
const LabelPhotoTest2 = await photo.getNthPhotoUid("image", 0);
await page.clickCardTitleOfUID(LabelPhotoTest2);
await t.click(photoedit.labelsTab);
await t.expect(Selector('.text-start').withText("上海").visible).ok();
await t.expect(Selector('.text-start').withText("伤害").visible).ok();
await t.click(photoedit.dialogClose);
});

View file

@ -105,7 +105,7 @@ func TestBatchPhotosEdit(t *testing.T) {
assert.Contains(t, albumsBefore.String(), "{\"value\":\"as6sg6bxpogaaba8\",\"title\":\"Holiday 2030\",\"mixed\":true,\"action\":\"none\"}")
labelsBefore := gjson.Get(editValues, "Labels")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy316\",\"title\":\"\\u0026friendship\",\"mixed\":true,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c4\",\"title\":\"Cake\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c4\",\"title\":\"Kuchen\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c3\",\"title\":\"Flower\",\"mixed\":true,\"action\":\"none\"}")
cameraBefore := gjson.Get(editValues, "CameraID")
assert.Equal(t, "{\"value\":-2,\"mixed\":true,\"action\":\"none\"}", cameraBefore.String())
@ -478,8 +478,8 @@ func TestBatchPhotosEdit(t *testing.T) {
assert.Contains(t, albumsBefore.String(), "{\"value\":\"as6sg6bxpogaaba8\",\"title\":\"Holiday 2030\",\"mixed\":true,\"action\":\"none\"}")
labelsBefore := gjson.Get(editValues, "Labels")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy316\",\"title\":\"\\u0026friendship\",\"mixed\":true,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c4\",\"title\":\"Cake\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c5\",\"title\":\"COW\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c4\",\"title\":\"Kuchen\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c5\",\"title\":\"Kuh\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c2\",\"title\":\"Landscape\",\"mixed\":false,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy3c3\",\"title\":\"Flower\",\"mixed\":true,\"action\":\"none\"}")
assert.Contains(t, labelsBefore.String(), "{\"value\":\"ls6sg6b1wowuy317\",\"title\":\"construction\\u0026failure\",\"mixed\":true,\"action\":\"none\"}")
@ -528,10 +528,10 @@ func TestBatchPhotosEdit(t *testing.T) {
assert.Equal(t, "&friendship", gjson.Get(r1.Body.String(), "Labels.2.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Labels.2.LabelSrc").String())
assert.Equal(t, "100", gjson.Get(r1.Body.String(), "Labels.2.Uncertainty").String())
assert.Equal(t, "COW", gjson.Get(r1.Body.String(), "Labels.3.Label.Name").String())
assert.Equal(t, "Kuh", gjson.Get(r1.Body.String(), "Labels.3.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Labels.3.LabelSrc").String())
assert.Equal(t, "100", gjson.Get(r1.Body.String(), "Labels.3.Uncertainty").String())
assert.Equal(t, "Cake", gjson.Get(r1.Body.String(), "Labels.4.Label.Name").String())
assert.Equal(t, "Kuchen", gjson.Get(r1.Body.String(), "Labels.4.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r1.Body.String(), "Labels.4.LabelSrc").String())
assert.Equal(t, "100", gjson.Get(r1.Body.String(), "Labels.4.Uncertainty").String())
assert.Equal(t, "Landscape", gjson.Get(r1.Body.String(), "Labels.5.Label.Name").String())
@ -549,7 +549,7 @@ func TestBatchPhotosEdit(t *testing.T) {
assert.Equal(t, "Flower", gjson.Get(r2.Body.String(), "Labels.1.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r2.Body.String(), "Labels.1.LabelSrc").String())
assert.Equal(t, "0", gjson.Get(r2.Body.String(), "Labels.1.Uncertainty").String())
assert.Equal(t, "COW", gjson.Get(r2.Body.String(), "Labels.2.Label.Name").String())
assert.Equal(t, "Kuh", gjson.Get(r2.Body.String(), "Labels.2.Label.Name").String())
assert.Equal(t, "batch", gjson.Get(r2.Body.String(), "Labels.2.LabelSrc").String())
assert.Equal(t, "100", gjson.Get(r2.Body.String(), "Labels.2.Uncertainty").String())
assert.Equal(t, "Landscape", gjson.Get(r2.Body.String(), "Labels.3.Label.Name").String())

View file

@ -40,7 +40,19 @@ func TestAddPhotoLabel(t *testing.T) {
r := PerformRequestWithBody(app, "POST", "/api/v1/photos/ps6sg6be2lvl0yh8/label", `{"Name": 123, "Uncertainty": 10, "Priority": 2}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("add homophone labels", func(t *testing.T) {
app, router, _ := NewApiTest()
AddPhotoLabel(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/photos/ps6sg6be2lvl0yh8/label", `{"Name": "老板", "Priority": 10}`)
assert.Equal(t, http.StatusOK, r.Code)
assert.Contains(t, r.Body.String(), "老板")
// t.Log(r.Body.String())
r2 := PerformRequestWithBody(app, "POST", "/api/v1/photos/ps6sg6be2lvl0yh8/label", `{"Name": "老伴", "Priority": 10}`)
assert.Equal(t, http.StatusOK, r2.Code)
assert.Contains(t, r2.Body.String(), "老板")
assert.Contains(t, r2.Body.String(), "老伴")
// t.Log(r2.Body.String())
})
}
func TestRemovePhotoLabel(t *testing.T) {
@ -113,7 +125,7 @@ func TestUpdatePhotoLabel(t *testing.T) {
name := gjson.Get(r.Body.String(), "Labels.#(LabelID==1000003).Label.Name")
assert.Equal(t, "0", uncertainty.String())
assert.Equal(t, "manual", src.String())
assert.Equal(t, "COW", name.String())
assert.Equal(t, "Kuh", name.String())
})
t.Run("photo not found", func(t *testing.T) {
app, router, _ := NewApiTest()

View file

@ -3262,6 +3262,48 @@ var FileFixtures = FileMap{
UpdatedIn: 123789,
DeletedAt: nil,
},
"2024112_090718_51A301B5.jpg": {
ID: 1000079,
Photo: PhotoFixtures.Pointer("Photo59"),
PhotoID: PhotoFixtures.Pointer("Photo59").ID,
PhotoUID: PhotoFixtures.Pointer("Photo59").PhotoUID,
InstanceID: "a698ac56-6e7e-42b9-9c3e-homophone01",
FileUID: "ft3cs9f4enhosfcl",
FileName: "2024/11/2024112_090718_51A301B5.jpg",
FileRoot: RootOriginals,
OriginalName: "shanghai-damage.jpg",
FileHash: "a0d1b2c3d4e3f3a3b3c9d0e1f2a3b4c5d6e7f8a9",
FileSize: 514000,
FileCodec: "jpeg",
FileType: "jpg",
MediaType: media.Image.String(),
FileMime: "image/jpeg",
FilePrimary: true,
FileSidecar: false,
FileVideo: false,
FileMissing: false,
FilePortrait: false,
FileDuration: 0,
FileWidth: 3840,
FileHeight: 2160,
FileOrientation: 1,
FileProjection: "",
FileAspectRatio: 1.7778,
FileMainColor: "black",
FileColors: "112233445",
FileLuminance: "556677889",
FileDiff: 512,
FileChroma: 18,
FileError: "",
Share: []FileShare{},
Sync: []FileSync{},
ModTime: time.Date(2024, 3, 15, 10, 5, 0, 0, time.UTC).Unix(),
CreatedAt: time.Date(2024, 3, 15, 10, 0, 0, 0, time.UTC),
CreatedIn: 123456,
UpdatedAt: time.Date(2024, 3, 15, 10, 5, 0, 0, time.UTC),
UpdatedIn: 123789,
DeletedAt: nil,
},
}
var FileFixturesExampleJPG = FileFixtures["exampleFileName.jpg"]

View file

@ -3,10 +3,14 @@ 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"
@ -48,6 +52,7 @@ type Label struct {
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.
@ -224,19 +229,59 @@ func (m *Label) Updates(values interface{}) error {
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
}
result := &Label{}
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 = ?)", m.CustomSlug, m.LabelSlug).
First(result).Error; err == nil {
return result
} else if createErr := m.Create(); createErr == nil {
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})
@ -246,10 +291,15 @@ func FirstOrCreateLabel(m *Label) *Label {
}
return m
} else if err = UnscopedDb().
Where("(custom_slug <> '' AND custom_slug = ? OR label_slug <> '' AND label_slug = ?)", m.CustomSlug, m.LabelSlug).
First(result).Error; err == nil {
return result
} 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)
}
@ -371,3 +421,69 @@ func (m *Label) UpdateClassify(label classify.Label) error {
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
}

View file

@ -3,6 +3,7 @@ package entity
import (
"errors"
"fmt"
"strings"
"sync"
"time"
@ -102,6 +103,7 @@ func FindLabel(name string, cached bool) (*Label, error) {
// Use the label slug as natural key cache.
cacheKey := txt.Slug(name)
cleanName := clean.LabelName(name)
if cacheKey == "" {
return &Label{}, fmt.Errorf("invalid label slug %s", clean.LogQuote(cacheKey))
@ -110,12 +112,32 @@ func FindLabel(name string, cached bool) (*Label, error) {
// Return cached label, if found.
if cached {
if cacheData, ok := labelCache.Get(cacheKey); ok {
log.Tracef("label: cache hit for %s", cacheKey)
// Get cached data.
if result := cacheData.(*Label); result.HasID() {
// Return cached entity.
return result, nil
if result.MaxHomophone != "" {
if strings.EqualFold(clean.LabelName(result.LabelName), cleanName) {
log.Tracef("label: homophone cache hit for %s", cacheKey)
return result, nil
} else {
// Walk the cache
for c := range result.MaxHomophone[0] - byte('a') + 1 {
key := fmt.Sprintf("%s-c-%c", cacheKey, c+byte('a'))
if cacheData2, ok := labelCache.Get(key); ok {
if result2 := cacheData2.(*Label); result2.HasID() {
if strings.EqualFold(clean.LabelName(result2.LabelName), cleanName) {
log.Tracef("label: homophone cache hit for %s", key)
return result2, nil
}
}
}
}
}
} else {
log.Tracef("label: cache hit for %s", cacheKey)
// Return cached entity.
return result, nil
}
} else {
// Return cached "not found" error.
return &Label{}, fmt.Errorf("label not found")
@ -125,15 +147,24 @@ func FindLabel(name string, cached bool) (*Label, error) {
// Fetch and cache label.
result := &Label{}
if find := Db().First(result, "(label_slug <> '' AND label_slug = ? OR custom_slug <> '' AND custom_slug = ?)", cacheKey, cacheKey); find.RecordNotFound() {
var labels []Label
slugLike := cacheKey + "-c-_"
if dbResult := Db().Where("(label_slug <> '' AND (label_slug = ? OR label_slug like ?)) OR (custom_slug <> '' AND custom_slug = ?)", cacheKey, slugLike, cacheKey).Find(&labels); dbResult.Error != nil {
labelCache.Set(cacheKey, result, labelCacheErrorExpiration)
log.Errorf("findlabel: label %s not found with error %s", name, dbResult.Error)
return result, dbResult.Error
}
if len(labels) == 0 {
labelCache.Set(cacheKey, result, labelCacheErrorExpiration)
return result, fmt.Errorf("label not found")
} else if find.Error != nil {
labelCache.Set(cacheKey, result, labelCacheErrorExpiration)
return result, find.Error
} else {
labelCache.SetDefault(result.LabelSlug, result)
for _, label := range labels {
if label.MaxHomophone == "" || strings.EqualFold(clean.LabelName(label.LabelName), cleanName) {
labelCache.SetDefault(label.LabelSlug, &label)
log.Tracef("label: db hit for %s", label.LabelSlug)
return &label, nil
}
}
}
return result, nil

View file

@ -37,6 +37,115 @@ func TestFindLabel(t *testing.T) {
assert.Error(t, err)
assert.NotNil(t, result)
})
t.Run("Homophone", func(t *testing.T) {
label1 := FirstOrCreateLabel(NewLabel("老板", 10))
if !assert.NotNil(t, label1) {
t.Fatal("label should not be nil")
}
label2 := FirstOrCreateLabel(NewLabel("老伴", 10))
if !assert.NotNil(t, label2) {
t.Fatal("label should not be nil")
}
label3 := FirstOrCreateLabel(NewLabel("lao-ban", 10))
if !assert.NotNil(t, label3) {
t.Fatal("label should not be nil")
}
uncached1, findErr := FindLabel("老板", false)
assert.NoError(t, findErr)
assert.Equal(t, "老板", uncached1.LabelName)
uncached2, findErr := FindLabel("老伴", false)
assert.NoError(t, findErr)
assert.Equal(t, "老伴", uncached2.LabelName)
uncached3, findErr := FindLabel("lao-ban", false)
assert.NoError(t, findErr)
assert.Equal(t, "Lao-Ban", uncached3.LabelName)
cached1, cacheErr := FindLabel("老板", true)
assert.NoError(t, cacheErr)
assert.Equal(t, "老板", cached1.LabelName)
assert.Equal(t, uncached1.LabelSlug, cached1.LabelSlug)
assert.Equal(t, uncached1.ID, cached1.ID)
assert.Equal(t, uncached1.LabelUID, cached1.LabelUID)
cached2, cacheErr := FindLabel("老伴", true)
assert.NoError(t, cacheErr)
assert.Equal(t, "老伴", cached2.LabelName)
assert.Equal(t, uncached2.LabelSlug, cached2.LabelSlug)
assert.Equal(t, uncached2.ID, cached2.ID)
assert.Equal(t, uncached2.LabelUID, cached2.LabelUID)
cached3, cacheErr := FindLabel("lao-ban", true)
assert.NoError(t, cacheErr)
assert.Equal(t, "Lao-Ban", cached3.LabelName)
assert.Equal(t, uncached3.LabelSlug, cached3.LabelSlug)
assert.Equal(t, uncached3.ID, cached3.ID)
assert.Equal(t, uncached3.LabelUID, cached3.LabelUID)
assert.NoError(t, UnscopedDb().Delete(&label1).Error)
assert.NoError(t, UnscopedDb().Delete(&label2).Error)
assert.NoError(t, UnscopedDb().Delete(&label3).Error)
})
t.Run("HomophoneCacheInvalidation", func(t *testing.T) {
label1 := FirstOrCreateLabel(NewLabel("老板", 10))
if !assert.NotNil(t, label1) {
t.Fatal("label should not be nil")
}
// Cache it before the MaxHomophone is populated
uncached1, findErr := FindLabel("老板", false)
assert.NoError(t, findErr)
assert.Equal(t, "老板", uncached1.LabelName)
label2 := FirstOrCreateLabel(NewLabel("老伴", 10))
if !assert.NotNil(t, label2) {
t.Fatal("label should not be nil")
}
label3 := FirstOrCreateLabel(NewLabel("lao-ban", 10))
if !assert.NotNil(t, label3) {
t.Fatal("label should not be nil")
}
// if the cache hasn't been invalidated, then this will fail.
uncached2, findErr := FindLabel("老伴", false)
assert.NoError(t, findErr)
assert.Equal(t, "老伴", uncached2.LabelName)
uncached3, findErr := FindLabel("lao-ban", false)
assert.NoError(t, findErr)
assert.Equal(t, "Lao-Ban", uncached3.LabelName)
cached1, cacheErr := FindLabel("老板", true)
assert.NoError(t, cacheErr)
assert.Equal(t, "老板", cached1.LabelName)
assert.Equal(t, uncached1.LabelSlug, cached1.LabelSlug)
assert.Equal(t, uncached1.ID, cached1.ID)
assert.Equal(t, uncached1.LabelUID, cached1.LabelUID)
cached2, cacheErr := FindLabel("老伴", true)
assert.NoError(t, cacheErr)
assert.Equal(t, "老伴", cached2.LabelName)
assert.Equal(t, uncached2.LabelSlug, cached2.LabelSlug)
assert.Equal(t, uncached2.ID, cached2.ID)
assert.Equal(t, uncached2.LabelUID, cached2.LabelUID)
cached3, cacheErr := FindLabel("lao-ban", true)
assert.NoError(t, cacheErr)
assert.Equal(t, "Lao-Ban", cached3.LabelName)
assert.Equal(t, uncached3.LabelSlug, cached3.LabelSlug)
assert.Equal(t, uncached3.ID, cached3.ID)
assert.Equal(t, uncached3.LabelUID, cached3.LabelUID)
assert.NoError(t, UnscopedDb().Delete(&label1).Error)
assert.NoError(t, UnscopedDb().Delete(&label2).Error)
assert.NoError(t, UnscopedDb().Delete(&label3).Error)
})
}
func TestFindPhotoLabel(t *testing.T) {

View file

@ -67,7 +67,7 @@ var LabelFixtures = LabelMap{
LabelUID: "ls6sg6b1wowuy3c4",
LabelSlug: "cake",
CustomSlug: "kuchen",
LabelName: "Cake",
LabelName: "Kuchen",
LabelPriority: 5,
LabelFavorite: false,
LabelDescription: "",
@ -84,7 +84,7 @@ var LabelFixtures = LabelMap{
LabelUID: "ls6sg6b1wowuy3c5",
LabelSlug: "cow",
CustomSlug: "kuh",
LabelName: "COW",
LabelName: "Kuh",
LabelPriority: -1,
LabelFavorite: true,
LabelDescription: "",
@ -572,6 +572,42 @@ var LabelFixtures = LabelMap{
DeletedAt: nil,
New: false,
},
"shanghai1": {
ID: 1000032,
LabelUID: "ls6sg6b1wowuy334",
LabelSlug: "shang-hai",
CustomSlug: "shang-hai",
LabelName: "上海",
LabelPriority: 1,
LabelFavorite: true,
LabelDescription: "",
LabelNotes: "",
PhotoCount: 1,
LabelCategories: []*Label{},
CreatedAt: Now(),
UpdatedAt: Now(),
DeletedAt: nil,
New: false,
MaxHomophone: "a",
},
"shanghai2": {
ID: 1000033,
LabelUID: "ls6sg6b1wowuy335",
LabelSlug: "shang-hai-c-a",
CustomSlug: "shang-hai-c-a",
LabelName: "伤害",
LabelPriority: 1,
LabelFavorite: true,
LabelDescription: "",
LabelNotes: "",
PhotoCount: 1,
LabelCategories: []*Label{},
CreatedAt: Now(),
UpdatedAt: Now(),
DeletedAt: nil,
New: false,
MaxHomophone: "a",
},
}
// CreateLabelFixtures inserts known entities into the database for testing.

View file

@ -11,6 +11,7 @@ import (
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
)
func TestNewLabel(t *testing.T) {
@ -26,6 +27,16 @@ func TestNewLabel(t *testing.T) {
assert.Equal(t, "unknown", label.LabelSlug)
assert.Equal(t, -6, label.LabelPriority)
})
t.Run("Homophones", func(t *testing.T) {
label := NewLabel("老板", 10)
assert.Equal(t, "老板", label.LabelName)
assert.Equal(t, "lao-ban", label.LabelSlug)
assert.Equal(t, 10, label.LabelPriority)
label = NewLabel("老伴", 10)
assert.Equal(t, "老伴", label.LabelName)
assert.Equal(t, "lao-ban", label.LabelSlug)
assert.Equal(t, 10, label.LabelPriority)
})
}
func TestLabel_TableName(t *testing.T) {
@ -172,20 +183,409 @@ func TestLabel_GetSlug(t *testing.T) {
}
func TestFirstOrCreateLabel(t *testing.T) {
label := LabelFixtures.Get("flower")
result := FirstOrCreateLabel(&label)
t.Run("First", func(t *testing.T) {
// Find flower
label := LabelFixtures.Get("flower")
result := FirstOrCreateLabel(&label)
if result == nil {
t.Fatal("result must not be nil")
}
if !assert.NotNil(t, result) {
t.Fatal("result should not be nil")
}
assert.Equal(t, label.LabelName, result.LabelName, "LabelName should be the same")
assert.Equal(t, label.LabelSlug, result.LabelSlug, "LabelSlug should be the same")
if result.LabelName != label.LabelName {
t.Errorf("LabelName should be the same: %s %s", result.LabelName, label.LabelName)
}
// Find Batch Delete
label = *NewLabel("Batch Delete", 10)
resultBase := FirstOrCreateLabel(&label)
if !assert.NotNil(t, resultBase) {
t.Fatal("resultBase should not be nil")
}
assert.Equal(t, LabelFixtures.Get("batchdelete").LabelUID, resultBase.LabelUID)
if result.LabelSlug != label.LabelSlug {
t.Errorf("LabelName should be the same: %s %s", result.LabelSlug, label.LabelSlug)
}
// Find Batch Delete with -
label = *NewLabel("Batch-Delete", 10)
result = FirstOrCreateLabel(&label)
if !assert.NotNil(t, result) {
t.Fatal("result should not be nil")
}
assert.Equal(t, LabelFixtures.Get("batchdelete").LabelUID, result.LabelUID)
// Find Batch Delete lowercase
label = *NewLabel("batch delete'", 10)
result = FirstOrCreateLabel(&label)
if !assert.NotNil(t, result) {
t.Fatal("result should not be nil")
}
assert.Equal(t, LabelFixtures.Get("batchdelete").LabelUID, result.LabelUID)
// Find Batch Delete with a unicode graphic character
label = *NewLabel("BATCH DELETE🢱", 10)
result = FirstOrCreateLabel(&label)
if !assert.NotNil(t, result) {
t.Fatal("result should not be nil")
}
assert.Equal(t, LabelFixtures.Get("batchdelete").LabelUID, result.LabelUID)
})
t.Run("Homophones", func(t *testing.T) {
// Add a homophone
label := NewLabel("老板", 10)
assert.Equal(t, "老板", label.LabelName)
assert.Equal(t, "lao-ban", label.LabelSlug)
assert.Equal(t, 10, label.LabelPriority)
result1 := FirstOrCreateLabel(label)
if !assert.NotNil(t, result1) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.Equal(t, label.LabelName, result1.LabelName, "LabelName should be the same")
assert.Equal(t, label.LabelSlug, result1.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, "", result1.MaxHomophone)
// Add another homophone
label2 := NewLabel("老伴", 10)
assert.Equal(t, "老伴", label2.LabelName)
assert.Equal(t, "lao-ban", label2.LabelSlug)
assert.Equal(t, 10, label2.LabelPriority)
result2 := FirstOrCreateLabel(label2)
if !assert.NotNil(t, result2) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.NotEqual(t, label.LabelName, label2.LabelName, "LabelName should not be the same")
assert.NotEqual(t, label.LabelSlug, label2.LabelSlug, "LabelSlug should not be the same")
assert.Equal(t, "lao-ban-c-a", result2.LabelSlug)
assert.Equal(t, "a", result2.MaxHomophone)
// Add the homophone in ascii
label3 := NewLabel("lao-ban", 10)
result3 := FirstOrCreateLabel(label3)
if !assert.NotNil(t, result3) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.NotEqual(t, label.LabelName, label3.LabelName, "LabelName should be the same")
assert.NotEqual(t, label.LabelSlug, label3.LabelSlug, "LabelSlug should not be the same")
assert.Equal(t, "lao-ban-c-b", result3.LabelSlug)
assert.Equal(t, "b", result3.MaxHomophone)
assert.NotEqual(t, result1.LabelUID, result2.LabelUID)
assert.NotEqual(t, result1.LabelUID, result3.LabelUID)
assert.NotEqual(t, result2.LabelUID, result3.LabelUID)
// Make sure that we find the correct homophone
label1a := NewLabel("老板", 10)
result1a := FirstOrCreateLabel(label1a)
if !assert.NotNil(t, result1a) {
t.Fatal("result should not be nil")
}
assert.Equal(t, result1.LabelUID, result1a.LabelUID)
assert.Equal(t, "b", result1a.MaxHomophone)
label2a := NewLabel("老伴", 10)
result2a := FirstOrCreateLabel(label2a)
if !assert.NotNil(t, result2a) {
t.Fatal("result should not be nil")
}
assert.Equal(t, result2.LabelUID, result2a.LabelUID)
assert.Equal(t, "b", result2a.MaxHomophone)
label3a := NewLabel("lao-ban", 10)
result3a := FirstOrCreateLabel(label3a)
if !assert.NotNil(t, result3a) {
t.Fatal("result should not be nil")
}
assert.Equal(t, result3.LabelUID, result3a.LabelUID)
assert.Equal(t, "b", result3a.MaxHomophone)
assert.NoError(t, UnscopedDb().Delete(&result1).Error)
assert.NoError(t, UnscopedDb().Delete(&result2).Error)
assert.NoError(t, UnscopedDb().Delete(&result3).Error)
})
t.Run("ExceedHomophones", func(t *testing.T) {
label := NewLabel("送钟", 10)
result := FirstOrCreateLabel(label)
//t.Logf("result = %+v", result)
assert.Equal(t, "song-zhong", result.LabelSlug)
assert.NotNil(t, result)
label = NewLabel("song-zhong", 10)
result = FirstOrCreateLabel(label)
assert.NotNil(t, result)
assert.Equal(t, "song-zhong-c-a", result.LabelSlug)
// Force increment to maximum
label = NewLabel("song-zhong-c-z", 10)
result = FirstOrCreateLabel(label)
assert.NotNil(t, result)
assert.Equal(t, "song-zhong-c-z", result.LabelSlug)
// This one should fail as we have run out of incrementers
label = NewLabel("送终", 10)
result = FirstOrCreateLabel(label)
//t.Logf("result = %+v", result)
assert.Nil(t, result)
})
t.Run("UnicodeEmoji", func(t *testing.T) {
// Test emitocons
label := NewLabel("😖😕", 10)
assert.Equal(t, "😖😕", label.LabelName)
assert.Equal(t, "_5cpzrfxqt5mjk", label.LabelSlug)
assert.Equal(t, 10, label.LabelPriority)
result := FirstOrCreateLabel(label)
if !assert.NotNil(t, result) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.Equal(t, "😖😕", result.LabelName, "LabelName should be the same")
assert.Equal(t, "_5cpzrfxqt5mjk", result.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, 10, result.LabelPriority)
label = NewLabel("😖😕", 1)
result2 := FirstOrCreateLabel(label)
if !assert.NotNil(t, result2) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.Equal(t, "😖😕", result2.LabelName, "LabelName should be the same")
assert.Equal(t, "_5cpzrfxqt5mjk", result2.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, result.LabelUID, result2.LabelUID, "LabelUID should be the same")
assert.Equal(t, 10, result2.LabelPriority)
// Test Unicode AND Emoticons
label = NewLabel("😖அஆஇ😕", 10)
assert.Equal(t, "😖அஆஇ😕", label.LabelName)
assert.Equal(t, "aaai", label.LabelSlug)
assert.Equal(t, 10, label.LabelPriority)
result = FirstOrCreateLabel(label)
if !assert.NotNil(t, result) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.Equal(t, "😖அஆஇ😕", result.LabelName, "LabelName should be the same")
assert.Equal(t, "aaai", result.LabelSlug, "LabelSlug should be the same")
label = NewLabel("😖அஆஇ😕", 1)
result2 = FirstOrCreateLabel(label)
if !assert.NotNil(t, result2) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.Equal(t, "😖அஆஇ😕", result2.LabelName, "LabelName should be the same")
assert.Equal(t, "aaai", result2.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, result.LabelUID, result2.LabelUID, "LabelUID should be the same")
assert.Equal(t, 10, result2.LabelPriority)
// Unicode Only to find with Emoticons
label = NewLabel("அஆஇ", 1)
result2 = FirstOrCreateLabel(label)
if !assert.NotNil(t, result2) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.Equal(t, "😖அஆஇ😕", result2.LabelName, "LabelName should be the same")
assert.Equal(t, "aaai", result2.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, result.LabelUID, result2.LabelUID, "LabelUID should be the same")
assert.Equal(t, 10, result2.LabelPriority)
// Test Unicode Only
label = NewLabel("அஆஇண", 10)
assert.Equal(t, "அஆஇண", label.LabelName)
assert.Equal(t, "aaainn", label.LabelSlug)
assert.Equal(t, 10, label.LabelPriority)
result = FirstOrCreateLabel(label)
if !assert.NotNil(t, result) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.Equal(t, "அஆஇண", result.LabelName, "LabelName should be the same")
assert.Equal(t, "aaainn", result.LabelSlug, "LabelSlug should be the same")
label = NewLabel("அஆஇண", 1)
result2 = FirstOrCreateLabel(label)
if !assert.NotNil(t, result2) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.Equal(t, "அஆஇண", result2.LabelName, "LabelName should be the same")
assert.Equal(t, "aaainn", result2.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, result.LabelUID, result2.LabelUID, "LabelUID should be the same")
assert.Equal(t, 10, result2.LabelPriority)
})
t.Run("Renamed", func(t *testing.T) {
// Find cow
label := LabelFixtures.Get("cow")
result := FirstOrCreateLabel(&label)
if !assert.NotNil(t, result) {
t.Fatal("result should not be nil")
}
assert.Equal(t, label.LabelName, result.LabelName, "LabelName should be the same")
assert.Equal(t, label.LabelSlug, result.LabelSlug, "LabelSlug should be the same")
label1 := NewLabel("Cow", 5)
result1 := FirstOrCreateLabel(label1)
require.NotNil(t, result1)
assert.Equal(t, label.ID, result1.ID)
})
t.Run("AmpersandVsAnd", func(t *testing.T) {
// Add base record
label := NewLabel("Fire and Station", 10)
assert.Equal(t, "Fire and Station", label.LabelName)
assert.Equal(t, "fire-and-station", label.LabelSlug)
assert.Equal(t, 10, label.LabelPriority)
result1 := FirstOrCreateLabel(label)
if !assert.NotNil(t, result1) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.Equal(t, label.LabelName, result1.LabelName, "LabelName should be the same")
assert.Equal(t, label.LabelSlug, result1.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, "", result1.MaxHomophone)
// Find Base Record with slug
label2 := NewLabel("Fire & Station", 10)
assert.Equal(t, "Fire & Station", label2.LabelName)
assert.Equal(t, "fire-and-station", label2.LabelSlug)
assert.Equal(t, 10, label2.LabelPriority)
result2 := FirstOrCreateLabel(label2)
if !assert.NotNil(t, result2) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.NotEqual(t, label.LabelName, label2.LabelName, "LabelName should not be the same")
assert.Equal(t, label.LabelSlug, label2.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, "fire-and-station", result2.LabelSlug)
assert.Equal(t, "", result2.MaxHomophone)
assert.Equal(t, result1.LabelUID, result2.LabelUID)
assert.NoError(t, UnscopedDb().Delete(&result1).Error)
assert.NoError(t, UnscopedDb().Delete(&result2).Error)
})
t.Run("AtVsAt", func(t *testing.T) {
// Add base record
label := NewLabel("老伴 at 伤害", 10)
assert.Equal(t, "老伴 at 伤害", label.LabelName)
assert.Equal(t, "lao-ban-at-shang-hai", label.LabelSlug)
assert.Equal(t, 10, label.LabelPriority)
result1 := FirstOrCreateLabel(label)
if !assert.NotNil(t, result1) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.Equal(t, label.LabelName, result1.LabelName, "LabelName should be the same")
assert.Equal(t, label.LabelSlug, result1.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, "", result1.MaxHomophone)
// Find Base Record with slug
label2 := NewLabel("老伴 @ 伤害", 10)
assert.Equal(t, "老伴 @ 伤害", label2.LabelName)
assert.Equal(t, "lao-ban-at-shang-hai", label2.LabelSlug)
assert.Equal(t, 10, label2.LabelPriority)
result2 := FirstOrCreateLabel(label2)
if !assert.NotNil(t, result2) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.NotEqual(t, label.LabelName, label2.LabelName, "LabelName should not be the same")
assert.Equal(t, label.LabelSlug, label2.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, "lao-ban-at-shang-hai", result2.LabelSlug)
assert.Equal(t, "", result2.MaxHomophone)
assert.Equal(t, result1.LabelUID, result2.LabelUID)
label3 := NewLabel("老伴 @ 上海", 10)
assert.Equal(t, "老伴 @ 上海", label3.LabelName)
assert.Equal(t, "lao-ban-at-shang-hai", label3.LabelSlug)
assert.Equal(t, 10, label3.LabelPriority)
result3 := FirstOrCreateLabel(label3)
if !assert.NotNil(t, result3) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.NotEqual(t, label.LabelName, label3.LabelName, "LabelName should not be the same")
assert.NotEqual(t, label.LabelSlug, label3.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, "lao-ban-at-shang-hai-c-a", result3.LabelSlug)
assert.Equal(t, "a", result3.MaxHomophone)
assert.NotEqual(t, result1.LabelUID, result3.LabelUID)
assert.NoError(t, UnscopedDb().Delete(&result1).Error)
assert.NoError(t, UnscopedDb().Delete(&result2).Error)
assert.NoError(t, UnscopedDb().Delete(&result3).Error)
})
t.Run("RemovedRunes", func(t *testing.T) {
// Add base record
label := NewLabel("Fire Station", 10)
assert.Equal(t, "Fire Station", label.LabelName)
assert.Equal(t, "fire-station", label.LabelSlug)
assert.Equal(t, 10, label.LabelPriority)
result1 := FirstOrCreateLabel(label)
if !assert.NotNil(t, result1) {
t.Fatal("result must not be nil")
}
//t.Logf("result = %+v", result)
assert.Equal(t, label.LabelName, result1.LabelName, "LabelName should be the same")
assert.Equal(t, label.LabelSlug, result1.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, "", result1.MaxHomophone)
// Find Base Record with slug
label2 := NewLabel("fire-station", 10)
assert.Equal(t, "Fire-Station", label2.LabelName)
assert.Equal(t, "fire-station", label2.LabelSlug)
assert.Equal(t, 10, label2.LabelPriority)
result2 := FirstOrCreateLabel(label2)
if !assert.NotNil(t, result2) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.NotEqual(t, label.LabelName, label2.LabelName, "LabelName should not be the same")
assert.Equal(t, label.LabelSlug, label2.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, "fire-station", result2.LabelSlug)
assert.Equal(t, "", result2.MaxHomophone)
// Find Base Record due to character removal
label3 := NewLabel("Fire+Station", 10)
assert.Equal(t, "Fire+Station", label3.LabelName)
assert.Equal(t, "fire-station", label3.LabelSlug)
assert.Equal(t, 10, label3.LabelPriority)
result3 := FirstOrCreateLabel(label3)
if !assert.NotNil(t, result3) {
t.Fatal("result should not be nil")
}
//t.Logf("result = %+v", result)
assert.NotEqual(t, label.LabelName, label3.LabelName, "LabelName should be the same")
assert.Equal(t, label.LabelSlug, label3.LabelSlug, "LabelSlug should be the same")
assert.Equal(t, "fire-station", result3.LabelSlug)
assert.Equal(t, "", result3.MaxHomophone)
assert.Equal(t, result1.LabelUID, result2.LabelUID)
assert.Equal(t, result1.LabelUID, result3.LabelUID)
assert.Equal(t, result2.LabelUID, result3.LabelUID)
assert.NoError(t, UnscopedDb().Delete(&result1).Error)
assert.NoError(t, UnscopedDb().Delete(&result2).Error)
assert.NoError(t, UnscopedDb().Delete(&result3).Error)
})
}
func TestLabel_UpdateClassify(t *testing.T) {
@ -388,3 +788,128 @@ func createTestLabel(t *testing.T, prefix string) *Label {
return label
}
func TestFindLabels(t *testing.T) {
t.Run("SuccessSingular", func(t *testing.T) {
label := LabelFixtures.Get("flower")
labels, err := FindLabels(label.LabelName, txt.Or, false)
assert.NoError(t, err)
if assert.Len(t, labels, 1) {
assert.Equal(t, label.ID, labels[0].ID)
}
})
t.Run("SuccessPlural", func(t *testing.T) {
label := LabelFixtures.Get("flower")
labels, err := FindLabels(label.LabelName+"s", txt.Or, false)
assert.NoError(t, err)
if assert.Len(t, labels, 1) {
assert.Equal(t, label.ID, labels[0].ID)
}
})
t.Run("SuccessMultiple", func(t *testing.T) {
label1 := LabelFixtures.Get("landscape")
label2 := LabelFixtures.Get("flower")
labels, err := FindLabels(fmt.Sprintf("%s%s%s", label1.LabelName, txt.Or, label2.LabelName), txt.Or, false)
assert.NoError(t, err)
found1 := false
found2 := false
if assert.Len(t, labels, 2) {
for _, label := range labels {
if label.ID == label1.ID {
found1 = true
} else if label.ID == label2.ID {
found2 = true
} else {
assert.Failf(t, "unable to match", "%+v", label)
}
}
assert.True(t, found1, "Unable to find %+v", label1)
assert.True(t, found2, "Unable to find %+v", label2)
}
})
t.Run("SuccessHomophone", func(t *testing.T) {
label1 := LabelFixtures.Get("shanghai1")
label2 := LabelFixtures.Get("shanghai2")
labels, err := FindLabels(label1.LabelName, txt.Or, false)
assert.NoError(t, err)
found1 := false
found2 := false
if assert.Len(t, labels, 1) {
for _, label := range labels {
if label.ID == label1.ID {
found1 = true
} else if label.ID == label2.ID {
found2 = true
} else {
assert.Failf(t, "unable to match", "%+v", label)
}
}
assert.True(t, found1, "Unable to find %+v", label1)
assert.False(t, found2, "Able to find %+v", label2)
}
})
t.Run("SuccessHomophones", func(t *testing.T) {
label1 := LabelFixtures.Get("shanghai1")
label2 := LabelFixtures.Get("shanghai2")
labels, err := FindLabels(fmt.Sprintf("%s%s%s", label1.LabelName, txt.Or, label2.LabelName), txt.Or, false)
assert.NoError(t, err)
found1 := false
found2 := false
if assert.Len(t, labels, 2) {
for _, label := range labels {
if label.ID == label1.ID {
found1 = true
} else if label.ID == label2.ID {
found2 = true
} else {
assert.Failf(t, "unable to match", "%+v", label)
}
}
assert.True(t, found1, "Unable to find %+v", label1)
assert.True(t, found2, "Unable to find %+v", label2)
}
})
t.Run("SuccessHomophoneSlug", func(t *testing.T) {
label1 := LabelFixtures.Get("shanghai1")
label2 := LabelFixtures.Get("shanghai2")
labels, err := FindLabels(label1.LabelSlug, txt.Or, false)
assert.NoError(t, err)
found1 := false
found2 := false
if assert.Len(t, labels, 1) {
for _, label := range labels {
if label.ID == label1.ID {
found1 = true
} else if label.ID == label2.ID {
found2 = true
} else {
assert.Failf(t, "unable to match", "%+v", label)
}
}
assert.True(t, found1, "Unable to find %+v", label1)
assert.False(t, found2, "Able to find %+v", label2)
}
})
t.Run("SuccessHomophoneSlug-A", func(t *testing.T) {
label1 := LabelFixtures.Get("shanghai1")
label2 := LabelFixtures.Get("shanghai2")
labels, err := FindLabels(label2.LabelSlug, txt.Or, false)
assert.NoError(t, err)
found1 := false
found2 := false
if assert.Len(t, labels, 1) {
for _, label := range labels {
if label.ID == label1.ID {
found1 = true
} else if label.ID == label2.ID {
found2 = true
} else {
assert.Failf(t, "unable to match", "%+v", label)
}
}
assert.False(t, found1, "Able to find %+v", label1)
assert.True(t, found2, "Unable to find %+v", label2)
}
})
}

View file

@ -3873,6 +3873,67 @@ var PhotoFixtures = PhotoMap{
PhotoStack: 0,
PhotoFaces: 0,
},
"Photo59": { //JPG
ID: 1000060,
PhotoUID: "ps6sg6byk7wrbk57",
TakenAt: time.Date(2024, 11, 12, 9, 7, 18, 0, time.UTC),
TakenAtLocal: time.Date(2024, 11, 12, 9, 7, 18, 0, time.UTC),
TakenSrc: SrcMeta,
PhotoType: "image",
TypeSrc: "",
PhotoTitle: "Shanghai 2024",
TitleSrc: SrcMeta,
PhotoCaption: "",
CaptionSrc: "",
PhotoPath: "2024/11",
PhotoName: "2024112_090718_51A301B5",
OriginalName: "",
PhotoFavorite: false,
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "Local",
Place: &UnknownPlace,
PlaceID: UnknownPlace.ID,
PlaceSrc: "",
Cell: &UnknownLocation,
CellID: UnknownLocation.ID,
CellAccuracy: 0,
PhotoAltitude: 0,
PhotoLat: 31.224361,
PhotoLng: 121.469170,
PhotoCountry: UnknownPlace.CountryCode(),
PhotoYear: 2024,
PhotoMonth: 11,
PhotoDay: 12,
PhotoIso: 0,
PhotoExposure: "",
PhotoFocalLength: 0,
PhotoFNumber: 0,
PhotoQuality: 3,
PhotoResolution: 0,
Camera: &UnknownCamera,
CameraID: UnknownCamera.ID,
CameraSerial: "",
CameraSrc: "",
Lens: &UnknownLens,
LensID: UnknownLens.ID,
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
Labels: []PhotoLabel{
LabelFixtures.PhotoLabel(1000057, "shanghai1", 38, "image"),
LabelFixtures.PhotoLabel(1000057, "shanghai2", 38, "manual"),
},
CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC),
EditedAt: nil,
CheckedAt: &checkedTime,
DeletedAt: nil,
PhotoColor: 14,
PhotoStack: 0,
PhotoFaces: 0,
},
}
// CreatePhotoFixtures inserts known entities into the database for testing.

View file

@ -220,7 +220,7 @@ func TestPhoto_LabelKeywordIndexing(t *testing.T) {
words := photoKeywordWords(t, photo.ID)
assert.Contains(t, words, "flower")
assert.Contains(t, words, "cake")
assert.Contains(t, words, "kuchen")
assert.NotContains(t, words, "cow") // SrcKeyword entries are skipped
})
t.Run("Optimize", func(t *testing.T) {

View file

@ -37,7 +37,7 @@ func TestLabelByUID(t *testing.T) {
}
assert.IsType(t, &entity.Label{}, result)
assert.Equal(t, "COW", result.LabelName)
assert.Equal(t, "Kuh", result.LabelName)
})
t.Run("NotFound", func(t *testing.T) {
result, err := LabelByUID("111")

View file

@ -61,29 +61,32 @@ func Labels(frm form.SearchLabels) (results []Label, err error) {
}
if frm.Query != "" {
var labelIds []uint
var labelIDs []uint
var categories []entity.Category
var label entity.Label
var labels []entity.Label
slugString := txt.Slug(frm.Query)
likeString := "%" + frm.Query + "%"
if result := Db().First(&label, "label_slug = ? OR custom_slug = ?", slugString, slugString); result.Error != nil {
if labels, err = entity.FindLabels(frm.Query, txt.Or, false); err != nil {
log.Errorf("search: label %s not found with error %s", clean.Log(frm.Query), err)
return results, err
}
if len(labels) == 0 {
log.Infof("search: label %s not found", clean.Log(frm.Query))
s = s.Where("labels.label_name LIKE ?", likeString)
} else {
labelIds = append(labelIds, label.ID)
for _, label := range labels {
labelIDs = append(labelIDs, label.ID)
Db().Where("category_id = ?", label.ID).Find(&categories)
Log("find categories", Db().Where("category_id = ?", label.ID).Find(&categories).Error)
log.Tracef("search: label %s includes %d categories", txt.LogParamLower(label.LabelName), len(categories))
for _, category := range categories {
labelIds = append(labelIds, category.LabelID)
for _, category := range categories {
labelIDs = append(labelIDs, category.LabelID)
}
}
log.Infof("search: label %s includes %d categories", clean.Log(label.LabelName), len(labelIds))
s = s.Where("labels.id IN (?)", labelIds)
s = s.Where("labels.id IN (?)", labelIDs)
}
}

View file

@ -4,6 +4,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/sortby"
@ -67,6 +68,36 @@ func TestLabels(t *testing.T) {
}
}
})
t.Run("SearchForKuh", func(t *testing.T) {
query := form.NewLabelSearch("Q:Kuh")
query.Count = 1005
query.Order = "slug"
result, err := Labels(query)
if err != nil {
t.Fatal(err)
}
t.Logf("results: %+v", result)
assert.LessOrEqual(t, 1, len(result))
for _, r := range result {
assert.IsType(t, Label{}, r)
assert.NotEmpty(t, r.ID)
assert.NotEmpty(t, r.LabelName)
assert.NotEmpty(t, r.LabelSlug)
assert.NotEmpty(t, r.CustomSlug)
if fix, ok := entity.LabelFixtures[r.LabelSlug]; ok {
assert.Equal(t, fix.LabelName, r.LabelName)
assert.Equal(t, fix.LabelSlug, r.LabelSlug)
assert.Equal(t, fix.CustomSlug, r.CustomSlug)
} else {
assert.Fail(t, "fixture not found by slug")
}
}
})
t.Run("SearchForFavorites", func(t *testing.T) {
query := form.NewLabelSearch("Favorite:true")
query.Count = 15
@ -197,4 +228,112 @@ func TestLabels(t *testing.T) {
assert.Equal(t, "flower", result[0].LabelSlug)
})
t.Run("SearchForHomophones", func(t *testing.T) {
t.Log("Create Label 老板")
label1 := entity.FirstOrCreateLabel(entity.NewLabel("老板", 10))
query := form.NewLabelSearch("q:老板")
query.Count = 5
query.Order = "slug"
result, err := Labels(query)
if err != nil {
t.Fatal(err)
}
t.Logf("results for search query q:老板: %+v", result)
if assert.Len(t, result, 1) {
assert.Equal(t, label1.LabelName, result[0].LabelName)
}
t.Log("Create Label 老伴")
label2 := entity.FirstOrCreateLabel(entity.NewLabel("老伴", 10))
assert.NotEqual(t, label1.ID, label2.ID)
query = form.NewLabelSearch("q:老板")
query.Count = 5
query.Order = "slug"
result, err = Labels(query)
if err != nil {
t.Fatal(err)
}
t.Logf("results for search query q:老板: %+v", result)
if assert.Len(t, result, 1) {
assert.Equal(t, label1.LabelName, result[0].LabelName)
}
query = form.NewLabelSearch("q:老伴")
query.Count = 5
query.Order = "slug"
result, err = Labels(query)
if err != nil {
t.Fatal(err)
}
t.Logf("results for search query q:老伴: %+v", result)
if assert.Len(t, result, 1) {
assert.Equal(t, label2.LabelName, result[0].LabelName)
}
query = form.NewLabelSearch("q:lao-ban")
query.Count = 5
query.Order = "slug"
result, err = Labels(query)
if err != nil {
t.Fatal(err)
}
t.Logf("results for search query q:lao-ban: %+v", result)
if assert.Len(t, result, 1) {
assert.Equal(t, label1.LabelName, result[0].LabelName)
}
t.Log("Rename Label 老板 to 老板renamed")
updFrm := &form.Label{LabelName: "老板renamed"}
require.NoError(t, label1.SaveForm(updFrm))
query = form.NewLabelSearch("q:老板renamed")
query.Count = 5
query.Order = "slug"
result, err = Labels(query)
if err != nil {
t.Fatal(err)
}
t.Logf("results for search query q:老板renamed: %+v", result)
if assert.Len(t, result, 1) {
assert.Equal(t, label1.LabelName, result[0].LabelName)
}
query = form.NewLabelSearch("q:lao-ban-renamed")
query.Count = 5
query.Order = "slug"
result, err = Labels(query)
if err != nil {
t.Fatal(err)
}
t.Logf("results for search query q:lao-ban-renamed: %+v", result)
if assert.Len(t, result, 1) {
assert.Equal(t, label1.LabelName, result[0].LabelName)
}
label1.Delete()
entity.UnscopedDb().Delete(label1)
label2.Delete()
entity.UnscopedDb().Delete(label2)
})
}

View file

@ -287,16 +287,11 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
// Filter by label, label category and keywords.
if txt.NotEmpty(frm.Label) {
var categories []entity.Category
var labels []entity.Label
var labelIds []uint
if labelErr := Db().Where(AnySlug("label_slug", frm.Label, txt.Or)).Or(AnySlug("custom_slug", frm.Label, txt.Or)).Find(&labels).Error; len(labels) == 0 || labelErr != nil {
log.Debugf("search: label %s not found", txt.LogParamLower(frm.Label))
return PhotoResults{}, 0, nil
} else {
if labels, labelErr := entity.FindLabels(frm.Label, txt.Or, false); len(labels) != 0 && labelErr == nil {
for _, l := range labels {
labelIds = append(labelIds, l.ID)
Log("find categories", Db().Where("category_id = ?", l.ID).Find(&categories).Error)
log.Debugf("search: label %s includes %d categories", txt.LogParamLower(l.LabelName), len(categories))
@ -307,6 +302,9 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
s = s.Joins("JOIN photos_labels ON photos_labels.photo_id = files.photo_id AND photos_labels.uncertainty < 100 AND photos_labels.label_id IN (?)", labelIds).
Group("photos.id, files.id")
} else {
log.Debugf("search: label %s not found", txt.LogParamLower(frm.Label))
return PhotoResults{}, 0, nil
}
}
@ -397,10 +395,9 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
// Filter by query string.
if frm.Query != "" {
var categories []entity.Category
var labels []entity.Label
var labelIds []uint
if labelsErr := Db().Where(AnySlug("custom_slug", frm.Query, " ")).Find(&labels).Error; len(labels) == 0 || labelsErr != nil {
if labels, labelsErr := entity.FindLabels(frm.Query, " ", false); len(labels) == 0 || labelsErr != nil {
log.Tracef("search: label %s not found, using fuzzy search", txt.LogParamLower(frm.Query))
for _, where := range LikeAnyKeyword("k.keyword", frm.Query) {
@ -410,8 +407,7 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
for _, l := range labels {
labelIds = append(labelIds, l.ID)
Db().Where("category_id = ?", l.ID).Find(&categories)
Log("find categories", Db().Where("category_id = ?", l.ID).Find(&categories).Error)
log.Tracef("search: label %s includes %d categories", txt.LogParamLower(l.LabelName), len(categories))
for _, category := range categories {

View file

@ -4,7 +4,9 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
)
@ -724,4 +726,96 @@ func TestPhotosQueryLabel(t *testing.T) {
}
assert.Len(t, photos, 2)
})
t.Run("Homophones", func(t *testing.T) {
var f form.SearchPhotos
f.Query = `label:"上海"`
f.Merged = true
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
f.Query = `label:"伤害"`
f.Merged = true
photos, _, err = Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
f.Query = `label:shang-hai`
f.Merged = true
photos, _, err = Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
// Rename the label
label1, err := entity.FindLabel("上海", true)
require.NoError(t, err)
updFrm := &form.Label{LabelName: "上海renamed"}
require.NoError(t, label1.SaveForm(updFrm))
f.Query = `label:"上海"`
f.Merged = true
photos, _, err = Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 0)
f.Query = `label:上海renamed`
f.Merged = true
photos, _, err = Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
f.Query = `label:shang-hai`
f.Merged = true
photos, _, err = Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
f.Query = `label:shang-hai-renamed`
f.Merged = true
photos, _, err = Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
updFrm = &form.Label{LabelName: "上海"}
require.NoError(t, label1.SaveForm(updFrm))
f.Query = `label:"上海"`
f.Merged = true
photos, _, err = Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
})
}

View file

@ -4,6 +4,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/form"
)
@ -260,7 +261,7 @@ func TestPhotosQueryPortrait(t *testing.T) {
photos, _, err := Photos(f)
assert.NoError(t, err)
require.NoError(t, err)
assert.Len(t, portraits, len(photos))
})
t.Run("OrSearch", func(t *testing.T) {
@ -283,7 +284,7 @@ func TestPhotosQueryPortrait(t *testing.T) {
photos, _, err := Photos(f)
assert.NoError(t, err)
assert.Equal(t, 8, len(photos))
assert.Len(t, photos, 9)
})
t.Run("Square", func(t *testing.T) {
var f form.SearchPhotos

View file

@ -209,13 +209,9 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
// Filter by label, label category and keywords.
if txt.NotEmpty(frm.Label) {
var categories []entity.Category
var labels []entity.Label
var labelIds []uint
if labelErr := Db().Where(AnySlug("label_slug", frm.Label, txt.Or)).Or(AnySlug("custom_slug", frm.Label, txt.Or)).Find(&labels).Error; len(labels) == 0 || labelErr != nil {
log.Debugf("search: label %s not found", txt.LogParamLower(frm.Label))
return GeoResults{}, nil
} else {
if labels, labelsErr := entity.FindLabels(frm.Label, txt.Or, false); len(labels) != 0 && labelsErr == nil {
for _, l := range labels {
labelIds = append(labelIds, l.ID)
@ -229,6 +225,9 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
s = s.Joins("JOIN photos_labels ON photos_labels.photo_id = files.photo_id AND photos_labels.uncertainty < 100 AND photos_labels.label_id IN (?)", labelIds).
Group("photos.id, files.id")
} else {
log.Debugf("search: label %s not found with error %+v", txt.LogParamLower(frm.Label), labelsErr)
return GeoResults{}, nil
}
}
@ -309,10 +308,9 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
// Filter by label, label category, and keywords.
if frm.Query != "" {
var categories []entity.Category
var labels []entity.Label
var labelIds []uint
if labelsErr := Db().Where(AnySlug("custom_slug", frm.Query, " ")).Find(&labels).Error; len(labels) == 0 || labelsErr != nil {
if labels, labelsErr := entity.FindLabels(frm.Query, " ", false); len(labels) == 0 || labelsErr != nil {
log.Tracef("search: label %s not found, using fuzzy search", txt.LogParamLower(frm.Query))
for _, where := range LikeAnyKeyword("k.keyword", frm.Query) {

View file

@ -134,6 +134,6 @@ func TestPhotosGeoFilterTime(t *testing.T) {
// t.Logf("After: %#v", photos)
assert.GreaterOrEqual(t, 4, len(photos))
assert.Len(t, photos, 5)
})
}

View file

@ -1,6 +1,7 @@
package search
import (
"fmt"
"testing"
"time"
@ -1194,4 +1195,234 @@ func TestGeo(t *testing.T) {
assert.Empty(t, r.PhotoTitle)
}
})
t.Run("query:label:ladder", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Query = "label:ladder"
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 4)
found51 := false
for _, r := range photos {
assert.IsType(t, GeoResult{}, r)
if fmt.Sprintf("%d", entity.PhotoFixtures.Get("photo51 ").ID) == r.ID {
found51 = true
}
}
assert.True(t, found51, "unable to find photo51 ")
})
t.Run("label:ladder\"", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Label = "ladder\""
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 4)
found51 := false
for _, r := range photos {
assert.IsType(t, GeoResult{}, r)
if fmt.Sprintf("%d", entity.PhotoFixtures.Get("photo51 ").ID) == r.ID {
found51 = true
}
}
assert.True(t, found51, "unable to find Photo51")
})
t.Run("query: label:上海", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Query = "label:上海"
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, GeoResult{}, r)
assert.Equal(t, fmt.Sprintf("%d", entity.PhotoFixtures.Get("Photo59").ID), r.ID)
}
})
t.Run("label:上海", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Label = "上海"
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, GeoResult{}, r)
assert.Equal(t, fmt.Sprintf("%d", entity.PhotoFixtures.Get("Photo59").ID), r.ID)
}
})
t.Run("query: label:伤害", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Query = "label:伤害"
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, GeoResult{}, r)
assert.Equal(t, fmt.Sprintf("%d", entity.PhotoFixtures.Get("Photo59").ID), r.ID)
}
})
t.Run("label:伤害", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Label = "伤害"
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, GeoResult{}, r)
assert.Equal(t, fmt.Sprintf("%d", entity.PhotoFixtures.Get("Photo59").ID), r.ID)
}
})
t.Run("query: label:Shang Hai", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Query = "label:Shang Hai"
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 0)
})
t.Run("label:Shang Hai", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Label = "Shang Hai" // homophone in latin char set
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 0)
})
t.Run("query: label:shang-hai", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Query = "label:shang-hai"
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, GeoResult{}, r)
assert.Equal(t, fmt.Sprintf("%d", entity.PhotoFixtures.Get("Photo59").ID), r.ID)
}
})
t.Run("label:shang-hai", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Label = "shang-hai" // homophone in latin char set
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, GeoResult{}, r)
assert.Equal(t, fmt.Sprintf("%d", entity.PhotoFixtures.Get("Photo59").ID), r.ID)
}
})
t.Run("query: label:shang-hai or 伤害", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Query = "label:shang-hai|伤害"
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, GeoResult{}, r)
assert.Equal(t, fmt.Sprintf("%d", entity.PhotoFixtures.Get("Photo59").ID), r.ID)
}
})
t.Run("label:shang-hai or 伤害", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Label = "shang-hai|伤害"
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, GeoResult{}, r)
assert.Equal(t, fmt.Sprintf("%d", entity.PhotoFixtures.Get("Photo59").ID), r.ID)
}
})
t.Run("query: shang-hai or 伤害", func(t *testing.T) {
var f form.SearchPhotosGeo
f.Query = "shang-hai 伤害"
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, GeoResult{}, r)
assert.Equal(t, fmt.Sprintf("%d", entity.PhotoFixtures.Get("Photo59").ID), r.ID)
}
})
}

View file

@ -2433,4 +2433,184 @@ func TestPhotos(t *testing.T) {
assert.NotEmpty(t, p.PhotoName)
}
})
t.Run("query: label:no-jpeg", func(t *testing.T) {
var f form.SearchPhotos
f.Query = "label:no-jpeg"
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, Photo{}, r)
assert.Equal(t, entity.PhotoFixtures.Get("Photo01").ID, r.ID)
}
})
t.Run("label:No Jpeg", func(t *testing.T) {
var f form.SearchPhotos
f.Label = "No Jpeg"
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, Photo{}, r)
assert.Equal(t, entity.PhotoFixtures.Get("Photo01").ID, r.ID)
}
})
t.Run("query: label:上海", func(t *testing.T) {
var f form.SearchPhotos
f.Query = "label:上海"
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, Photo{}, r)
assert.Equal(t, entity.PhotoFixtures.Get("Photo59").ID, r.ID)
}
})
t.Run("label:上海", func(t *testing.T) {
var f form.SearchPhotos
f.Label = "上海"
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, Photo{}, r)
assert.Equal(t, entity.PhotoFixtures.Get("Photo59").ID, r.ID)
}
})
t.Run("query: label:伤害", func(t *testing.T) {
var f form.SearchPhotos
f.Query = "label:伤害"
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, Photo{}, r)
assert.Equal(t, entity.PhotoFixtures.Get("Photo59").ID, r.ID)
}
})
t.Run("label:伤害", func(t *testing.T) {
var f form.SearchPhotos
f.Label = "伤害"
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, Photo{}, r)
assert.Equal(t, entity.PhotoFixtures.Get("Photo59").ID, r.ID)
}
})
t.Run("query: label:shang-hai", func(t *testing.T) {
var f form.SearchPhotos
f.Query = "label:shang-hai" // homophone in latin char set
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, Photo{}, r)
assert.Equal(t, entity.PhotoFixtures.Get("Photo59").ID, r.ID)
}
})
t.Run("label:shang-hai", func(t *testing.T) {
var f form.SearchPhotos
f.Label = "shang-hai-c-a" // homophone in latin char set
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, Photo{}, r)
assert.Equal(t, entity.PhotoFixtures.Get("Photo59").ID, r.ID)
}
})
t.Run("query: label:shang-hai or 伤害", func(t *testing.T) {
var f form.SearchPhotos
f.Query = "label:shang-hai|伤害"
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, Photo{}, r)
assert.Equal(t, entity.PhotoFixtures.Get("Photo59").ID, r.ID)
}
})
t.Run("label:shang-hai or 伤害", func(t *testing.T) {
var f form.SearchPhotos
f.Label = "shang-hai|伤害"
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Len(t, photos, 1)
for _, r := range photos {
assert.IsType(t, Photo{}, r)
assert.Equal(t, entity.PhotoFixtures.Get("Photo59").ID, r.ID)
}
})
}

38
pkg/clean/label.go Normal file
View file

@ -0,0 +1,38 @@
package clean
import (
"strings"
"unicode"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/gosimple/slug"
)
// LabelName cleans a string so that it can be compared against other LabelNames to ensure
// that LabelSlug clashes are only created appropriately
func LabelName(s string) string {
s = strings.ReplaceAll(s, "-", " ")
s = strings.ReplaceAll(s, "@", "at")
s = strings.ReplaceAll(s, "&", "and")
s = strings.TrimSpace(s)
if s == "" || s == "-" {
return s
}
if s[0] == txt.SlugEncoded && txt.ContainsAlnumLower(s[1:]) {
return txt.Clip(s, txt.ClipSlug)
}
if slug.Make(s) == "" {
return s
}
cleanLabel := strings.Map(func(r rune) rune {
if unicode.IsDigit(r) || unicode.IsLetter(r) || unicode.IsSpace(r) {
return unicode.ToLower(r)
}
return -1
}, s)
return cleanLabel
}

48
pkg/clean/label_test.go Normal file
View file

@ -0,0 +1,48 @@
package clean
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLabelName(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, "", LabelName(""))
})
t.Run("Fire Station", func(t *testing.T) {
assert.Equal(t, "fire station", LabelName("Fire Station"))
assert.Equal(t, "fire station", LabelName("Fire' Station"))
assert.Equal(t, "fire station", LabelName("😀Fire Station"))
assert.Equal(t, "fire station", LabelName("Fire Station◨"))
assert.Equal(t, "fire station", LabelName("Fire-Station"))
})
t.Run("ABC", func(t *testing.T) {
assert.Equal(t, "abc", LabelName("Abc"))
assert.Equal(t, "abc", LabelName("abc."))
assert.Equal(t, "abc", LabelName(".abc."))
assert.Equal(t, "abc", LabelName("😀ABC "))
assert.Equal(t, "abc", LabelName("aB◨c"))
assert.Equal(t, "abc", LabelName("abc-"))
})
t.Run("Unicode", func(t *testing.T) {
assert.Equal(t, "送钟", LabelName("送钟"))
assert.Equal(t, "送终", LabelName("送终"))
assert.Equal(t, "送 终", LabelName("送-终"))
assert.Equal(t, "送 终", LabelName(" 送 终 "))
assert.Equal(t, "送终", LabelName("-送终"))
})
t.Run("Mixed", func(t *testing.T) {
assert.Equal(t, "送钟abc", LabelName("送钟AbC"))
assert.Equal(t, аbc", LabelName(аBc"))
assert.Equal(t, "так", LabelName("ТАК"))
})
t.Run("Graphic", func(t *testing.T) {
assert.Equal(t, "😀", LabelName("😀"))
assert.Equal(t, "◨", LabelName("◨"))
assert.Equal(t, "😀◨😀", LabelName("😀◨😀"))
assert.Equal(t, "😀◨😀", LabelName(" 😀◨😀 "))
assert.Equal(t, "😀 ◨😀", LabelName(" 😀-◨😀 "))
assert.Equal(t, "😀◨ 😀", LabelName(" 😀◨ 😀 "))
})
}