Backend: Improve Yes/No capabilities #5191

* Backend: improve Yes/No capabilities
* Backend: constants package with Yes/No maps and True/False strings
* Backend: rename constants to enum
* Backend: correct case on russian Yes, more tests
* Enum: utilise enum package
This commit is contained in:
Keith Martin
2025-10-10 21:15:15 +10:00
committed by GitHub
parent 1d216f2dfc
commit b1822229ca
14 changed files with 239 additions and 101 deletions

View File

@@ -3,6 +3,7 @@ package acl
import (
"strings"
"github.com/photoprism/photoprism/pkg/enum"
"github.com/photoprism/photoprism/pkg/list"
)
@@ -93,9 +94,9 @@ func ScopeAttrPermits(attr list.Attr, resource Resource, perms Permissions) bool
}
// Check if scope is limited to read or write operations.
if a := attr.Find(ScopeRead.String()); a.Value == list.True && GrantScopeRead.DenyAny(perms) {
if a := attr.Find(ScopeRead.String()); a.Value == enum.True && GrantScopeRead.DenyAny(perms) {
return false
} else if a = attr.Find(ScopeWrite.String()); a.Value == list.True && GrantScopeWrite.DenyAny(perms) {
} else if a = attr.Find(ScopeWrite.String()); a.Value == enum.True && GrantScopeWrite.DenyAny(perms) {
return false
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/enum"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/geo"
"github.com/photoprism/photoprism/pkg/geo/pluscode"
@@ -711,9 +712,9 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
// Filter by title.
if txt.NotEmpty(frm.Title) {
if frm.Title == txt.False {
if frm.Title == enum.False {
s = s.Where("photos.photo_title = ''")
} else if frm.Title == txt.True {
} else if frm.Title == enum.True {
s = s.Where("photos.photo_title <> ''")
} else {
where, values := OrLike("photos.photo_title", frm.Title)
@@ -723,9 +724,9 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
// Filter by caption.
if txt.NotEmpty(frm.Caption) {
if frm.Caption == txt.False {
if frm.Caption == enum.False {
s = s.Where("photos.photo_caption = ''")
} else if frm.Caption == txt.True {
} else if frm.Caption == enum.True {
s = s.Where("photos.photo_caption <> ''")
} else {
where, values := OrLike("photos.photo_caption", frm.Caption)
@@ -735,9 +736,9 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
// Filter by description.
if txt.NotEmpty(frm.Description) {
if frm.Description == txt.False {
if frm.Description == enum.False {
s = s.Where("photos.photo_title = '' AND photos.photo_caption = ''")
} else if frm.Description == txt.True {
} else if frm.Description == enum.True {
s = s.Where("photos.photo_title <> '' OR photos.photo_caption <> ''")
} else {
where, values := OrLikeCols([]string{"photos.photo_title", "photos.photo_caption"}, frm.Description)

View File

@@ -15,6 +15,7 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/enum"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/geo"
"github.com/photoprism/photoprism/pkg/geo/pluscode"
@@ -583,9 +584,9 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
// Filter by title.
if txt.NotEmpty(frm.Title) {
if frm.Title == txt.False {
if frm.Title == enum.False {
s = s.Where("photos.photo_title = ''")
} else if frm.Title == txt.True {
} else if frm.Title == enum.True {
s = s.Where("photos.photo_title <> ''")
} else {
where, values := OrLike("photos.photo_title", frm.Title)
@@ -595,9 +596,9 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
// Filter by caption.
if txt.NotEmpty(frm.Caption) {
if frm.Caption == txt.False {
if frm.Caption == enum.False {
s = s.Where("photos.photo_caption = ''")
} else if frm.Caption == txt.True {
} else if frm.Caption == enum.True {
s = s.Where("photos.photo_caption <> ''")
} else {
where, values := OrLike("photos.photo_caption", frm.Caption)
@@ -607,9 +608,9 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
// Filter by description.
if txt.NotEmpty(frm.Description) {
if frm.Description == txt.False {
if frm.Description == enum.False {
s = s.Where("photos.photo_title = '' AND photos.photo_caption = ''")
} else if frm.Description == txt.True {
} else if frm.Description == enum.True {
s = s.Where("photos.photo_title <> '' OR photos.photo_caption <> ''")
} else {
where, values := OrLikeCols([]string{"photos.photo_title", "photos.photo_caption"}, frm.Description)

View File

@@ -74,7 +74,7 @@ func TestParseQueryStringLabel(t *testing.T) {
assert.Equal(t, "unknown filter: xxx", err.Error())
})
t.Run("QueryForFavoritesWithUncommonBoolValue", func(t *testing.T) {
form := &SearchLabels{Query: "favorite:0.99"}
form := &SearchLabels{Query: "favorite:0"}
err := form.ParseQueryString()

View File

@@ -20,6 +20,7 @@ import (
"github.com/photoprism/photoprism/internal/mutex"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/enum"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -152,7 +153,7 @@ func (w *Vision) Start(filter string, count int, models []string, customSrc stri
// Find photos without captions when only
// captions are updated without force flag.
if !updateLabels && !updateNsfw && !force {
frm.Caption = txt.False
frm.Caption = enum.False
}
photos, _, queryErr := search.Photos(frm)

25
pkg/enum/enum.go Normal file
View File

@@ -0,0 +1,25 @@
/*
Package enum provides declarations of enum.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package enum

41
pkg/enum/maps.go Normal file
View File

@@ -0,0 +1,41 @@
package enum
// YesMap map to represent Yes in the following languages Czech, Danish, Dutch, English, French, German, Indonesian, Italian, Polish, Portuguese, Russian, Ukrainian.
var YesMap = map[string]struct{}{
"1": {},
"yes": {},
"include": {},
"true": {},
"positive": {},
"please": {},
"ano": {},
"ja": {},
"oui": {},
"si": {},
"tak": {},
"sim": {},
"да": {},
"ya": {},
"так": {},
}
// NoMap map to represent No in the following languages Czech, Danish, Dutch, English, French, German, Indonesian, Italian, Polish, Portuguese, Russian, Ukrainian.
var NoMap = map[string]struct{}{
"0": {},
"no": {},
"none": {},
"exclude": {},
"false": {},
"negative": {},
"unknown": {},
"žádný": {},
"ingen": {},
"nee": {},
"nein": {},
"non": {},
"nie": {},
"não": {},
"нет": {},
"tidak": {},
"ні": {},
}

7
pkg/enum/strings.go Normal file
View File

@@ -0,0 +1,7 @@
package enum
// True and False specify boolean string representations.
const (
True = "true"
False = "false"
)

View File

@@ -3,6 +3,8 @@ package list
import (
"fmt"
"strings"
"github.com/photoprism/photoprism/pkg/enum"
)
// KeyValue represents a key-value attribute.
@@ -77,7 +79,7 @@ func (f *KeyValue) Parse(s string) *KeyValue {
if f.Key == Any {
return f
} else if v = Value(v); v == "" {
f.Value = True
f.Value = enum.True
return f
}
@@ -101,7 +103,7 @@ func (f *KeyValue) String() string {
return Any
}
if Bool[strings.ToLower(f.Value)] == True {
if Bool[strings.ToLower(f.Value)] == enum.True {
return f.Key
}

View File

@@ -3,6 +3,8 @@ package list
import (
"sort"
"strings"
"github.com/photoprism/photoprism/pkg/enum"
)
// Attr represents a list of key-value attributes.
@@ -84,7 +86,7 @@ func (list Attr) Sort() Attr {
func (list Attr) Contains(s string) bool {
attr := list.Find(s)
if attr.Key == "" || attr.Value == False {
if attr.Key == "" || attr.Value == enum.False {
return false
}
@@ -120,14 +122,14 @@ func (list Attr) Find(s string) (a KeyValue) {
} else {
for i := range list {
if strings.EqualFold(attr.Key, list[i].Key) {
if attr.Value == True && list[i].Value == False {
if attr.Value == enum.True && list[i].Value == enum.False {
return KeyValue{Key: "", Value: ""}
} else if attr.Value == list[i].Value {
return *list[i]
} else if list[i].Value == Any {
a = *list[i]
}
} else if list[i].Key == Any && attr.Value != False {
} else if list[i].Key == Any && attr.Value != enum.False {
a = *list[i]
}
}

View File

@@ -4,6 +4,8 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/enum"
)
func TestParseAttr(t *testing.T) {
@@ -202,7 +204,7 @@ func TestAttr_Find(t *testing.T) {
assert.Len(t, attr, 7)
result := attr.Find("people.view")
assert.Equal(t, "people.view", result.Key)
assert.Equal(t, True, result.Value)
assert.Equal(t, enum.True, result.Value)
})
t.Run("ReadAll", func(t *testing.T) {
s := "read *"
@@ -211,7 +213,7 @@ func TestAttr_Find(t *testing.T) {
assert.Len(t, attr, 2)
result := attr.Find("read")
assert.Equal(t, "read", result.Key)
assert.Equal(t, True, result.Value)
assert.Equal(t, enum.True, result.Value)
})
t.Run("ReadFalse", func(t *testing.T) {
s := "read:false *"
@@ -220,10 +222,10 @@ func TestAttr_Find(t *testing.T) {
assert.Len(t, attr, 2)
result := attr.Find("read:*")
assert.Equal(t, "read", result.Key)
assert.Equal(t, False, result.Value)
assert.Equal(t, enum.False, result.Value)
result = attr.Find("read:false")
assert.Equal(t, "read", result.Key)
assert.Equal(t, False, result.Value)
assert.Equal(t, enum.False, result.Value)
})
t.Run("ReadOther", func(t *testing.T) {
s := "read:other *"

View File

@@ -1,22 +1,18 @@
package list
import "github.com/photoprism/photoprism/pkg/enum"
// StringLengthLimit specifies the maximum length of string return values.
var StringLengthLimit = 767
// True and False specify boolean string representations.
var (
True = "true"
False = "false"
)
// Bool specifies boolean string values so they can be normalized.
var Bool = map[string]string{
"true": True,
"yes": True,
"on": True,
"enable": True,
"false": False,
"no": False,
"off": False,
"disable": False,
"true": enum.True,
"yes": enum.True,
"on": enum.True,
"enable": enum.True,
"false": enum.False,
"no": enum.False,
"off": enum.False,
"disable": enum.False,
}

View File

@@ -2,11 +2,8 @@ package txt
import (
"strings"
)
const (
True = "true"
False = "false"
"github.com/photoprism/photoprism/pkg/enum"
)
// Bool casts a string to bool.
@@ -20,26 +17,31 @@ func Bool(s string) bool {
return true
}
// Yes tests if a string represents "yes".
func Yes(s string) bool {
if s == "" {
// Yes tests if a string represents "yes" in the following languages Czech, Danish, Dutch, English, French, German, Indonesian, Italian, Polish, Portuguese, Russian, Ukrainian.
func Yes(s string) (result bool) {
t := strings.ToLower(strings.TrimSpace(s))
if t == "" {
return false
} else if strings.Contains(t, " ") {
result = false
} else {
_, result = enum.YesMap[t]
}
s = strings.ToLower(strings.TrimSpace(s))
return strings.IndexAny(s, "ytjposiд") == 0
return result
}
// No tests if a string represents "no".
func No(s string) bool {
if s == "" {
// No tests if a string represents "no" in the following languages Czech, Danish, Dutch, English, French, German, Indonesian, Italian, Polish, Portuguese, Russian, Ukrainian.
func No(s string) (result bool) {
t := strings.ToLower(strings.TrimSpace(s))
if t == "" {
return false
} else if strings.Contains(t, " ") {
result = false
} else {
_, result = enum.NoMap[t]
}
s = strings.ToLower(strings.TrimSpace(s))
return strings.IndexAny(s, "0nhufeн") == 0
return result
}
// New tests if a string represents "new".

View File

@@ -8,144 +8,201 @@ import (
func TestBool(t *testing.T) {
t.Run("NotEmpty", func(t *testing.T) {
assert.Equal(t, true, Bool("Browse your life in pictures"))
assert.True(t, Bool("Browse your life in pictures"))
})
t.Run("Oui", func(t *testing.T) {
assert.Equal(t, true, Bool("oui"))
assert.True(t, Bool("oui"))
})
t.Run("Non", func(t *testing.T) {
assert.Equal(t, false, Bool("non"))
assert.False(t, Bool("non"))
})
t.Run("Ja", func(t *testing.T) {
assert.Equal(t, true, Bool("ja"))
assert.True(t, Bool("ja"))
})
t.Run("True", func(t *testing.T) {
assert.Equal(t, true, Bool("true"))
assert.True(t, Bool("true"))
})
t.Run("Yes", func(t *testing.T) {
assert.Equal(t, true, Bool("yes"))
assert.True(t, Bool("yes"))
})
t.Run("No", func(t *testing.T) {
assert.Equal(t, false, Bool("no"))
assert.False(t, Bool("no"))
})
t.Run("False", func(t *testing.T) {
assert.Equal(t, false, Bool("false"))
assert.False(t, Bool("false"))
})
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, false, Bool(""))
assert.False(t, Bool(""))
})
}
func TestYes(t *testing.T) {
t.Run("NotEmpty", func(t *testing.T) {
assert.Equal(t, false, Yes("Browse your life in pictures"))
assert.False(t, Yes("Browse your life in pictures"))
})
t.Run("Oui", func(t *testing.T) {
assert.Equal(t, true, Yes("oui"))
assert.True(t, Yes("oui"))
assert.True(t, Yes("OUI"))
})
t.Run("Non", func(t *testing.T) {
assert.Equal(t, false, Yes("non"))
assert.False(t, Yes("non"))
})
t.Run("Ja", func(t *testing.T) {
assert.Equal(t, true, Yes("ja"))
assert.True(t, Yes("ja"))
})
t.Run("True", func(t *testing.T) {
assert.Equal(t, true, Yes("true"))
assert.True(t, Yes("true"))
})
t.Run("Yes", func(t *testing.T) {
assert.Equal(t, true, Yes("yes"))
assert.True(t, Yes("yes"))
})
t.Run("No", func(t *testing.T) {
assert.Equal(t, false, Yes("no"))
assert.False(t, Yes("no"))
})
t.Run("False", func(t *testing.T) {
assert.Equal(t, false, Yes("false"))
assert.False(t, Yes("false"))
})
t.Run("Exclude", func(t *testing.T) {
assert.Equal(t, false, Yes("exclude"))
assert.False(t, Yes("exclude"))
})
t.Run("Include", func(t *testing.T) {
assert.Equal(t, true, Yes("include"))
assert.True(t, Yes("include"))
})
t.Run("Unknown", func(t *testing.T) {
assert.Equal(t, false, Yes("unknown"))
assert.False(t, Yes("unknown"))
})
t.Run("Please", func(t *testing.T) {
assert.Equal(t, true, Yes("please"))
assert.True(t, Yes("please"))
assert.True(t, Yes("pLeAsE"))
})
t.Run("Positive", func(t *testing.T) {
assert.Equal(t, true, Yes("positive"))
assert.True(t, Yes("positive"))
})
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, false, Yes(""))
assert.False(t, Yes(""))
})
t.Run("Space", func(t *testing.T) {
assert.False(t, Yes("Yes Please"))
})
t.Run("One", func(t *testing.T) {
assert.True(t, Yes("1"))
})
t.Run("Zero", func(t *testing.T) {
assert.False(t, Yes("0"))
})
t.Run("tak", func(t *testing.T) {
assert.True(t, Yes("так"))
assert.True(t, Yes("ТАК"))
})
t.Run("russian", func(t *testing.T) {
assert.True(t, Yes("да"))
assert.True(t, Yes("Да"))
})
}
func TestNo(t *testing.T) {
t.Run("NotEmpty", func(t *testing.T) {
assert.Equal(t, false, No("Browse your life in pictures"))
assert.False(t, No("Browse your life in pictures"))
})
t.Run("Oui", func(t *testing.T) {
assert.Equal(t, false, No("oui"))
assert.False(t, No("oui"))
assert.False(t, No("OUI"))
})
t.Run("Non", func(t *testing.T) {
assert.Equal(t, true, No("non"))
assert.True(t, No("non"))
})
t.Run("Ja", func(t *testing.T) {
assert.Equal(t, false, No("ja"))
assert.False(t, No("ja"))
})
t.Run("True", func(t *testing.T) {
assert.Equal(t, false, No("true"))
assert.False(t, No("true"))
})
t.Run("Yes", func(t *testing.T) {
assert.Equal(t, false, No("yes"))
assert.False(t, No("yes"))
})
t.Run("No", func(t *testing.T) {
assert.Equal(t, true, No("no"))
assert.True(t, No("no"))
})
t.Run("False", func(t *testing.T) {
assert.Equal(t, true, No("false"))
assert.True(t, No("false"))
})
t.Run("Exclude", func(t *testing.T) {
assert.Equal(t, true, No("exclude"))
assert.True(t, No("exclude"))
})
t.Run("Include", func(t *testing.T) {
assert.Equal(t, false, No("include"))
assert.False(t, No("include"))
})
t.Run("Unknown", func(t *testing.T) {
assert.Equal(t, true, No("unknown"))
assert.True(t, No("unknown"))
})
t.Run("Please", func(t *testing.T) {
assert.Equal(t, false, No("please"))
assert.False(t, No("please"))
})
t.Run("Positive", func(t *testing.T) {
assert.Equal(t, false, No("positive"))
assert.False(t, No("positive"))
})
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, false, No(""))
assert.False(t, No(""))
})
t.Run("Space", func(t *testing.T) {
assert.False(t, No("No Thanks"))
})
t.Run("One", func(t *testing.T) {
assert.False(t, No("1"))
})
t.Run("Zero", func(t *testing.T) {
assert.True(t, No("0"))
})
t.Run("HiAccent", func(t *testing.T) {
assert.True(t, No("ні"))
assert.True(t, No("НІ"))
})
t.Run("Hi", func(t *testing.T) {
assert.False(t, No("Hi"))
})
t.Run("Zadny", func(t *testing.T) {
assert.True(t, No("žádný"))
assert.True(t, No("ŽÁDNÝ"))
})
t.Run("Nao", func(t *testing.T) {
assert.True(t, No("não"))
assert.True(t, No("NÃO"))
})
t.Run("Het", func(t *testing.T) {
assert.True(t, No("нет"))
assert.True(t, No("НЕТ"))
})
t.Run("Ingen", func(t *testing.T) {
assert.True(t, No("ingen"))
})
t.Run("Nee", func(t *testing.T) {
assert.True(t, No("nee"))
})
t.Run("Nein", func(t *testing.T) {
assert.True(t, No("nein"))
})
}
func TestNew(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, false, New(""))
assert.False(t, New(""))
})
t.Run("EnNew", func(t *testing.T) {
assert.Equal(t, true, New(EnNew))
assert.True(t, New(EnNew))
})
t.Run("Spaces", func(t *testing.T) {
assert.Equal(t, true, New(" new "))
assert.True(t, New(" new "))
})
t.Run("Uppercase", func(t *testing.T) {
assert.Equal(t, true, New("NEW"))
assert.True(t, New("NEW"))
})
t.Run("Lowercase", func(t *testing.T) {
assert.Equal(t, true, New("new"))
assert.True(t, New("new"))
})
t.Run("True", func(t *testing.T) {
assert.Equal(t, true, New("New"))
assert.True(t, New("New"))
})
t.Run("False", func(t *testing.T) {
assert.Equal(t, false, New("non"))
assert.False(t, New("non"))
})
}