mirror of
https://github.com/photoprism/photoprism.git
synced 2026-04-22 16:07:25 +08:00
Search: Add NOT & AND operators to label filter #5535
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -220,3 +220,155 @@ func FindLabelIDs(search, sep string, includeCategories bool) ([]uint, error) {
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ErrLabelNotFound reports that a positive label-filter AND group matched no
|
||||
// known labels. Callers use this to short-circuit to an empty result set while
|
||||
// still distinguishing the case from "only negative groups given".
|
||||
var ErrLabelNotFound = fmt.Errorf("label not found")
|
||||
|
||||
// unescapeLabelTerm decodes '\\', '\!', '\|', '\&' escape sequences in a single
|
||||
// label search term to their literal characters. Escape sequences in front of
|
||||
// other runes are preserved so existing escape-free terms are untouched.
|
||||
func unescapeLabelTerm(s string) string {
|
||||
if !strings.ContainsRune(s, txt.EscapeRune) {
|
||||
return s
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
|
||||
escaped := false
|
||||
|
||||
for _, r := range s {
|
||||
if escaped {
|
||||
switch r {
|
||||
case '!', txt.OrRune, txt.AndRune, txt.EscapeRune:
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune(txt.EscapeRune)
|
||||
b.WriteRune(r)
|
||||
}
|
||||
|
||||
escaped = false
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if r == txt.EscapeRune {
|
||||
escaped = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
b.WriteRune(r)
|
||||
}
|
||||
|
||||
if escaped {
|
||||
b.WriteRune(txt.EscapeRune)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// resolveLabelGroup unions the category-expanded label IDs for every '|'
|
||||
// alternative in a single AND group, respecting escape sequences.
|
||||
func resolveLabelGroup(group string) (ids []uint) {
|
||||
alts := txt.TrimmedSplitWithEscape(group, txt.OrRune, txt.EscapeRune)
|
||||
|
||||
if len(alts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[uint]struct{}, len(alts))
|
||||
|
||||
add := func(id uint) {
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := seen[id]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
for _, alt := range alts {
|
||||
alt = strings.TrimSpace(unescapeLabelTerm(alt))
|
||||
|
||||
if alt == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
altIDs, lookupErr := FindLabelIDs(alt, txt.Or, true)
|
||||
if lookupErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, id := range altIDs {
|
||||
add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// ParseLabelFilter splits a label filter into positive and negative groups of
|
||||
// category-expanded label IDs. Each element of include is the OR-expanded ID
|
||||
// set for one positive AND group (all positive groups must match); each element
|
||||
// of exclude is the OR-expanded ID set for one negative AND group (none must
|
||||
// match). A leading '!' on an AND group marks it as an exclusion; an escaped
|
||||
// '\\!' is treated as a literal label-name character. sawPositive reports
|
||||
// whether at least one positive group was parsed, so callers can distinguish
|
||||
// "only negative groups" from "unknown positive label => empty result". An
|
||||
// ErrLabelNotFound return means a positive group resolved to zero labels and
|
||||
// the caller should short-circuit to an empty result set.
|
||||
func ParseLabelFilter(s string) (include, exclude [][]uint, sawPositive bool, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
if s == "" {
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
|
||||
groups := txt.TrimmedSplitWithEscape(s, txt.AndRune, txt.EscapeRune)
|
||||
|
||||
for _, group := range groups {
|
||||
group = strings.TrimSpace(group)
|
||||
|
||||
if group == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
negate := strings.HasPrefix(group, "!")
|
||||
|
||||
if negate {
|
||||
group = strings.TrimSpace(group[1:])
|
||||
}
|
||||
|
||||
if group == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
ids := resolveLabelGroup(group)
|
||||
|
||||
if len(ids) == 0 {
|
||||
if negate {
|
||||
continue
|
||||
}
|
||||
|
||||
sawPositive = true
|
||||
|
||||
return nil, nil, true, ErrLabelNotFound
|
||||
}
|
||||
|
||||
if negate {
|
||||
exclude = append(exclude, ids)
|
||||
} else {
|
||||
sawPositive = true
|
||||
include = append(include, ids)
|
||||
}
|
||||
}
|
||||
|
||||
return include, exclude, sawPositive, nil
|
||||
}
|
||||
|
||||
@@ -72,3 +72,152 @@ func TestFindLabelIDs(t *testing.T) {
|
||||
assert.Len(t, labelIDs, 2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnescapeLabelTerm(t *testing.T) {
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
assert.Equal(t, "", unescapeLabelTerm(""))
|
||||
})
|
||||
t.Run("WithoutEscape", func(t *testing.T) {
|
||||
assert.Equal(t, "cake", unescapeLabelTerm("cake"))
|
||||
})
|
||||
t.Run("EscapedOperators", func(t *testing.T) {
|
||||
assert.Equal(t, "!weird", unescapeLabelTerm(`\!weird`))
|
||||
assert.Equal(t, "cat&dog", unescapeLabelTerm(`cat\&dog`))
|
||||
assert.Equal(t, "a|b", unescapeLabelTerm(`a\|b`))
|
||||
assert.Equal(t, `a\b`, unescapeLabelTerm(`a\\b`))
|
||||
})
|
||||
t.Run("UnknownEscapePreserved", func(t *testing.T) {
|
||||
assert.Equal(t, `a\bc`, unescapeLabelTerm(`a\bc`))
|
||||
})
|
||||
t.Run("TrailingEscapePreserved", func(t *testing.T) {
|
||||
assert.Equal(t, `a\`, unescapeLabelTerm(`a\`))
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveLabelGroup(t *testing.T) {
|
||||
t.Run("SingleExact", func(t *testing.T) {
|
||||
ids := resolveLabelGroup("cake")
|
||||
assert.Equal(t, []uint{LabelFixtures.Get("cake").ID}, ids)
|
||||
})
|
||||
t.Run("OrAlternatives", func(t *testing.T) {
|
||||
ids := resolveLabelGroup("cake|cow")
|
||||
assert.Contains(t, ids, LabelFixtures.Get("cake").ID)
|
||||
assert.Contains(t, ids, LabelFixtures.Get("cow").ID)
|
||||
})
|
||||
t.Run("CategoryExpansion", func(t *testing.T) {
|
||||
ids := resolveLabelGroup("landscape")
|
||||
assert.Contains(t, ids, LabelFixtures.Get("landscape").ID)
|
||||
assert.Contains(t, ids, LabelFixtures.Get("flower").ID)
|
||||
})
|
||||
t.Run("UnknownReturnsEmpty", func(t *testing.T) {
|
||||
assert.Empty(t, resolveLabelGroup("totally-unknown-label-name"))
|
||||
})
|
||||
t.Run("EscapedAmpersandName", func(t *testing.T) {
|
||||
ids := resolveLabelGroup(`construction\&failure`)
|
||||
assert.Equal(t, []uint{LabelFixtures.Get("construction&failure").ID}, ids)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseLabelFilter(t *testing.T) {
|
||||
cakeID := LabelFixtures.Get("cake").ID
|
||||
cowID := LabelFixtures.Get("cow").ID
|
||||
flowerID := LabelFixtures.Get("flower").ID
|
||||
landscapeID := LabelFixtures.Get("landscape").ID
|
||||
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
include, exclude, sawPositive, err := ParseLabelFilter("")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, sawPositive)
|
||||
assert.Empty(t, include)
|
||||
assert.Empty(t, exclude)
|
||||
})
|
||||
t.Run("SinglePositive", func(t *testing.T) {
|
||||
include, exclude, sawPositive, err := ParseLabelFilter("cake")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, sawPositive)
|
||||
require.Len(t, include, 1)
|
||||
assert.Equal(t, []uint{cakeID}, include[0])
|
||||
assert.Empty(t, exclude)
|
||||
})
|
||||
t.Run("SingleNegative", func(t *testing.T) {
|
||||
include, exclude, sawPositive, err := ParseLabelFilter("!cake")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, sawPositive)
|
||||
assert.Empty(t, include)
|
||||
require.Len(t, exclude, 1)
|
||||
assert.Equal(t, []uint{cakeID}, exclude[0])
|
||||
})
|
||||
t.Run("IncludeAndExclude", func(t *testing.T) {
|
||||
include, exclude, sawPositive, err := ParseLabelFilter("cake&!flower")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, sawPositive)
|
||||
require.Len(t, include, 1)
|
||||
assert.Equal(t, []uint{cakeID}, include[0])
|
||||
require.Len(t, exclude, 1)
|
||||
assert.Equal(t, []uint{flowerID}, exclude[0])
|
||||
})
|
||||
t.Run("MultipleIncludes", func(t *testing.T) {
|
||||
include, exclude, sawPositive, err := ParseLabelFilter("cake&cow")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, sawPositive)
|
||||
require.Len(t, include, 2)
|
||||
assert.Equal(t, []uint{cakeID}, include[0])
|
||||
assert.Equal(t, []uint{cowID}, include[1])
|
||||
assert.Empty(t, exclude)
|
||||
})
|
||||
t.Run("OrWithinPositiveGroup", func(t *testing.T) {
|
||||
include, _, sawPositive, err := ParseLabelFilter("cake|cow")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, sawPositive)
|
||||
require.Len(t, include, 1)
|
||||
assert.ElementsMatch(t, []uint{cakeID, cowID}, include[0])
|
||||
})
|
||||
t.Run("UnknownPositiveShortCircuits", func(t *testing.T) {
|
||||
_, _, sawPositive, err := ParseLabelFilter("cake&totally-unknown-label")
|
||||
assert.ErrorIs(t, err, ErrLabelNotFound)
|
||||
assert.True(t, sawPositive)
|
||||
})
|
||||
t.Run("UnknownNegativeIsNoOp", func(t *testing.T) {
|
||||
include, exclude, sawPositive, err := ParseLabelFilter("cake&!totally-unknown-label")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, sawPositive)
|
||||
require.Len(t, include, 1)
|
||||
assert.Empty(t, exclude)
|
||||
})
|
||||
t.Run("NegativeCategoryExpansion", func(t *testing.T) {
|
||||
_, exclude, _, err := ParseLabelFilter("!landscape")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, exclude, 1)
|
||||
assert.Contains(t, exclude[0], landscapeID)
|
||||
assert.Contains(t, exclude[0], flowerID)
|
||||
})
|
||||
t.Run("EscapedLeadingBang", func(t *testing.T) {
|
||||
// A leading \! is a literal label name. No label named "!weird"
|
||||
// exists in fixtures, so the lookup short-circuits.
|
||||
_, _, sawPositive, err := ParseLabelFilter(`\!weird`)
|
||||
assert.ErrorIs(t, err, ErrLabelNotFound)
|
||||
assert.True(t, sawPositive)
|
||||
})
|
||||
t.Run("NegatedEscapedBang", func(t *testing.T) {
|
||||
// "!\!weird" = NOT a label literally named "!weird"; unknown negative is a no-op.
|
||||
include, exclude, sawPositive, err := ParseLabelFilter(`!\!weird`)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, sawPositive)
|
||||
assert.Empty(t, include)
|
||||
assert.Empty(t, exclude)
|
||||
})
|
||||
t.Run("EscapedAmpersandName", func(t *testing.T) {
|
||||
include, _, sawPositive, err := ParseLabelFilter(`construction\&failure`)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, sawPositive)
|
||||
require.Len(t, include, 1)
|
||||
assert.Equal(t, []uint{LabelFixtures.Get("construction&failure").ID}, include[0])
|
||||
})
|
||||
t.Run("LoneBangDropped", func(t *testing.T) {
|
||||
include, exclude, sawPositive, err := ParseLabelFilter("!")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, sawPositive)
|
||||
assert.Empty(t, include)
|
||||
assert.Empty(t, exclude)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -286,12 +286,27 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
|
||||
|
||||
// Filter by label, label category and keywords.
|
||||
if txt.NotEmpty(frm.Label) {
|
||||
if labelIds, labelErr := entity.FindLabelIDs(frm.Label, txt.Or, true); labelErr != nil || len(labelIds) == 0 {
|
||||
include, exclude, sawPositive, labelErr := entity.ParseLabelFilter(frm.Label)
|
||||
|
||||
if labelErr != nil || (sawPositive && len(include) == 0) {
|
||||
log.Debugf("search: label %s not found", txt.LogParamLower(frm.Label))
|
||||
return PhotoResults{}, 0, nil
|
||||
} else {
|
||||
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")
|
||||
}
|
||||
|
||||
// JOIN the first positive group so photos_labels.uncertainty is
|
||||
// available for the "relevance" sort order; subsequent positive
|
||||
// groups compose as AND via IN subqueries.
|
||||
for i, ids := range include {
|
||||
if i == 0 {
|
||||
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 (?)", ids).
|
||||
Group("photos.id, files.id")
|
||||
} else {
|
||||
s = s.Where("files.photo_id IN (SELECT photo_id FROM photos_labels WHERE uncertainty < 100 AND label_id IN (?))", ids)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ids := range exclude {
|
||||
s = s.Where("files.photo_id NOT IN (SELECT photo_id FROM photos_labels WHERE uncertainty < 100 AND label_id IN (?))", ids)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,9 +102,11 @@ func TestPhotosFilterLabel(t *testing.T) {
|
||||
assert.Len(t, photos, 1)
|
||||
})
|
||||
t.Run("StartsWithAmpersand", func(t *testing.T) {
|
||||
// Option A: a literal '&' inside a label name must be escaped because
|
||||
// the unescaped form is now parsed as an AND separator between groups.
|
||||
var f form.SearchPhotos
|
||||
|
||||
f.Label = "&friendship"
|
||||
f.Label = `\&friendship`
|
||||
f.Merged = true
|
||||
|
||||
photos, _, err := Photos(f)
|
||||
@@ -118,7 +120,7 @@ func TestPhotosFilterLabel(t *testing.T) {
|
||||
t.Run("CenterAmpersand", func(t *testing.T) {
|
||||
var f form.SearchPhotos
|
||||
|
||||
f.Label = "construction&failure"
|
||||
f.Label = `construction\&failure`
|
||||
f.Merged = true
|
||||
|
||||
photos, _, err := Photos(f)
|
||||
@@ -132,7 +134,7 @@ func TestPhotosFilterLabel(t *testing.T) {
|
||||
t.Run("EndsWithAmpersand", func(t *testing.T) {
|
||||
var f form.SearchPhotos
|
||||
|
||||
f.Label = "goal&"
|
||||
f.Label = `goal\&`
|
||||
f.Merged = true
|
||||
|
||||
photos, _, err := Photos(f)
|
||||
@@ -463,7 +465,7 @@ func TestPhotosQueryLabel(t *testing.T) {
|
||||
t.Run("StartsWithAmpersand", func(t *testing.T) {
|
||||
var f form.SearchPhotos
|
||||
|
||||
f.Query = "label:\"&friendship\""
|
||||
f.Query = `label:"\&friendship"`
|
||||
f.Merged = true
|
||||
|
||||
photos, _, err := Photos(f)
|
||||
@@ -477,7 +479,7 @@ func TestPhotosQueryLabel(t *testing.T) {
|
||||
t.Run("CenterAmpersand", func(t *testing.T) {
|
||||
var f form.SearchPhotos
|
||||
|
||||
f.Query = "label:\"construction&failure\""
|
||||
f.Query = `label:"construction\&failure"`
|
||||
f.Merged = true
|
||||
|
||||
photos, _, err := Photos(f)
|
||||
@@ -491,7 +493,7 @@ func TestPhotosQueryLabel(t *testing.T) {
|
||||
t.Run("EndsWithAmpersand", func(t *testing.T) {
|
||||
var f form.SearchPhotos
|
||||
|
||||
f.Query = "label:\"goal&\""
|
||||
f.Query = `label:"goal\&"`
|
||||
f.Merged = true
|
||||
|
||||
photos, _, err := Photos(f)
|
||||
@@ -745,3 +747,130 @@ func TestPhotosQueryLabel(t *testing.T) {
|
||||
assert.Equal(t, photoB.PhotoUID, photos[0].PhotoUID)
|
||||
})
|
||||
}
|
||||
|
||||
// baselinePhotoCount returns the merged-photo result count for a search with no
|
||||
// label filter. Used by NOT/AND tests to derive expected counts.
|
||||
func baselinePhotoCount(t *testing.T) int {
|
||||
t.Helper()
|
||||
|
||||
var f form.SearchPhotos
|
||||
|
||||
f.Merged = true
|
||||
|
||||
photos, _, err := Photos(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return len(photos)
|
||||
}
|
||||
|
||||
// photosWithLabel runs a label filter search and returns the merged photos.
|
||||
func photosWithLabel(t *testing.T, label string) PhotoResults {
|
||||
t.Helper()
|
||||
|
||||
var f form.SearchPhotos
|
||||
|
||||
f.Label = label
|
||||
f.Merged = true
|
||||
|
||||
photos, _, err := Photos(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return photos
|
||||
}
|
||||
|
||||
// containsPhotoUID reports whether any of the photos in results carries the
|
||||
// given UID.
|
||||
func containsPhotoUID(results PhotoResults, uid string) bool {
|
||||
for _, p := range results {
|
||||
if p.PhotoUID == uid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func TestPhotosFilterLabelNotAnd(t *testing.T) {
|
||||
t.Run("SingleExclude", func(t *testing.T) {
|
||||
base := baselinePhotoCount(t)
|
||||
withFlower := len(photosWithLabel(t, "flower"))
|
||||
result := photosWithLabel(t, "!flower")
|
||||
assert.Equal(t, base-withFlower, len(result))
|
||||
})
|
||||
t.Run("ExcludeUnknownIsNoOp", func(t *testing.T) {
|
||||
base := baselinePhotoCount(t)
|
||||
result := photosWithLabel(t, "!totally-unknown-label")
|
||||
assert.Equal(t, base, len(result))
|
||||
})
|
||||
t.Run("OnlyNegative", func(t *testing.T) {
|
||||
base := baselinePhotoCount(t)
|
||||
withCake := len(photosWithLabel(t, "cake"))
|
||||
result := photosWithLabel(t, "!cake")
|
||||
assert.Equal(t, base-withCake, len(result))
|
||||
})
|
||||
t.Run("IncludeAndExclude", func(t *testing.T) {
|
||||
result := photosWithLabel(t, "cake&!flower")
|
||||
assert.Len(t, result, 2)
|
||||
})
|
||||
t.Run("MultipleIncludes", func(t *testing.T) {
|
||||
result := photosWithLabel(t, "cake&flower")
|
||||
assert.Len(t, result, 3)
|
||||
})
|
||||
t.Run("MultipleIncludesPlusExclude", func(t *testing.T) {
|
||||
result := photosWithLabel(t, "cake&flower&!cow")
|
||||
assert.Len(t, result, 1)
|
||||
})
|
||||
t.Run("IncludeOrExcludeBlurry", func(t *testing.T) {
|
||||
result := photosWithLabel(t, "cake|flower&!cow")
|
||||
assert.Len(t, result, 2)
|
||||
})
|
||||
t.Run("CategoryExpansion", func(t *testing.T) {
|
||||
base := baselinePhotoCount(t)
|
||||
withLandscape := len(photosWithLabel(t, "landscape"))
|
||||
result := photosWithLabel(t, "!landscape")
|
||||
assert.Equal(t, base-withLandscape, len(result))
|
||||
assert.Greater(t, withLandscape, 0)
|
||||
})
|
||||
t.Run("EscapeLiteralBang", func(t *testing.T) {
|
||||
// No fixture label named "!weird" exists, so a positive lookup for the
|
||||
// escaped literal short-circuits to an empty result set.
|
||||
result := photosWithLabel(t, `\!weird`)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
t.Run("EscapeLiteralBangNegated", func(t *testing.T) {
|
||||
// Negating an unknown literal label "!weird" is a no-op.
|
||||
base := baselinePhotoCount(t)
|
||||
result := photosWithLabel(t, `!\!weird`)
|
||||
assert.Equal(t, base, len(result))
|
||||
})
|
||||
t.Run("LegacyAmpersandName", func(t *testing.T) {
|
||||
escaped := photosWithLabel(t, `construction\&failure`)
|
||||
assert.Len(t, escaped, 2)
|
||||
|
||||
// The same input without escape is now parsed as two positive AND
|
||||
// groups: neither "construction" nor "failure" exists as a fixture
|
||||
// label, so the result short-circuits to empty.
|
||||
unescaped := photosWithLabel(t, "construction&failure")
|
||||
assert.Empty(t, unescaped)
|
||||
})
|
||||
t.Run("OnlyNegativeTwoGroups", func(t *testing.T) {
|
||||
base := baselinePhotoCount(t)
|
||||
// With disjoint label sets, the count equals base minus union; since
|
||||
// flower ⊂ landscape-category here, use an independent pair.
|
||||
withCake := len(photosWithLabel(t, "cake"))
|
||||
withCow := len(photosWithLabel(t, "cow"))
|
||||
result := photosWithLabel(t, "!cake&!cow")
|
||||
// The difference must be between base-(cake+cow) and base-max(cake,cow).
|
||||
maxSide := withCake
|
||||
if withCow > maxSide {
|
||||
maxSide = withCow
|
||||
}
|
||||
|
||||
assert.LessOrEqual(t, len(result), base-maxSide)
|
||||
assert.GreaterOrEqual(t, len(result), base-(withCake+withCow))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -208,12 +208,19 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
|
||||
|
||||
// Filter by label, label category and keywords.
|
||||
if txt.NotEmpty(frm.Label) {
|
||||
if labelIds, labelErr := entity.FindLabelIDs(frm.Label, txt.Or, true); labelErr != nil || len(labelIds) == 0 {
|
||||
include, exclude, sawPositive, labelErr := entity.ParseLabelFilter(frm.Label)
|
||||
|
||||
if labelErr != nil || (sawPositive && len(include) == 0) {
|
||||
log.Debugf("search: label %s not found", txt.LogParamLower(frm.Label))
|
||||
return GeoResults{}, nil
|
||||
} else {
|
||||
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")
|
||||
}
|
||||
|
||||
for _, ids := range include {
|
||||
s = s.Where("files.photo_id IN (SELECT photo_id FROM photos_labels WHERE uncertainty < 100 AND label_id IN (?))", ids)
|
||||
}
|
||||
|
||||
for _, ids := range exclude {
|
||||
s = s.Where("files.photo_id NOT IN (SELECT photo_id FROM photos_labels WHERE uncertainty < 100 AND label_id IN (?))", ids)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
)
|
||||
|
||||
// geoBaselineCount returns the merged geo-result count for a search with no
|
||||
// label filter.
|
||||
func geoBaselineCount(t *testing.T) int {
|
||||
t.Helper()
|
||||
|
||||
result, err := PhotosGeo(form.NewSearchPhotosGeo(""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return len(result)
|
||||
}
|
||||
|
||||
// geoCountForLabel runs a geo search with the given label filter and returns
|
||||
// the number of results.
|
||||
func geoCountForLabel(t *testing.T, label string) int {
|
||||
t.Helper()
|
||||
|
||||
q := form.NewSearchPhotosGeo("")
|
||||
q.Label = label
|
||||
|
||||
result, err := PhotosGeo(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return len(result)
|
||||
}
|
||||
|
||||
func TestPhotosGeoFilterLabel(t *testing.T) {
|
||||
t.Run("SingleInclude", func(t *testing.T) {
|
||||
assert.Greater(t, geoCountForLabel(t, "cake"), 0)
|
||||
})
|
||||
t.Run("SingleExclude", func(t *testing.T) {
|
||||
base := geoBaselineCount(t)
|
||||
withFlower := geoCountForLabel(t, "flower")
|
||||
result := geoCountForLabel(t, "!flower")
|
||||
assert.Equal(t, base-withFlower, result)
|
||||
})
|
||||
t.Run("ExcludeUnknownIsNoOp", func(t *testing.T) {
|
||||
base := geoBaselineCount(t)
|
||||
result := geoCountForLabel(t, "!totally-unknown-label")
|
||||
assert.Equal(t, base, result)
|
||||
})
|
||||
t.Run("OnlyNegative", func(t *testing.T) {
|
||||
base := geoBaselineCount(t)
|
||||
withCake := geoCountForLabel(t, "cake")
|
||||
result := geoCountForLabel(t, "!cake")
|
||||
assert.Equal(t, base-withCake, result)
|
||||
})
|
||||
t.Run("IncludeAndExclude", func(t *testing.T) {
|
||||
// cake&!flower is empty on the geo subset because every geotagged
|
||||
// cake photo is also tagged with flower.
|
||||
result := geoCountForLabel(t, "cake&!flower")
|
||||
assert.Equal(t, 0, result)
|
||||
})
|
||||
t.Run("MultipleIncludes", func(t *testing.T) {
|
||||
result := geoCountForLabel(t, "cake&flower")
|
||||
assert.Equal(t, 2, result)
|
||||
})
|
||||
t.Run("MultipleIncludesPlusExclude", func(t *testing.T) {
|
||||
result := geoCountForLabel(t, "cake&flower&!cow")
|
||||
assert.Equal(t, 0, result)
|
||||
})
|
||||
t.Run("IncludeOrExcludeCow", func(t *testing.T) {
|
||||
result := geoCountForLabel(t, "cake|flower&!cow")
|
||||
// All geotagged cake-or-flower photos also carry cow in fixtures.
|
||||
assert.Equal(t, 0, result)
|
||||
})
|
||||
t.Run("CategoryExpansion", func(t *testing.T) {
|
||||
base := geoBaselineCount(t)
|
||||
withLandscape := geoCountForLabel(t, "landscape")
|
||||
assert.Greater(t, withLandscape, 0)
|
||||
assert.Equal(t, base-withLandscape, geoCountForLabel(t, "!landscape"))
|
||||
})
|
||||
t.Run("EscapeLiteralBang", func(t *testing.T) {
|
||||
// No fixture label named "!weird" exists; positive lookup short-circuits.
|
||||
assert.Equal(t, 0, geoCountForLabel(t, `\!weird`))
|
||||
})
|
||||
t.Run("EscapeLiteralBangNegated", func(t *testing.T) {
|
||||
base := geoBaselineCount(t)
|
||||
assert.Equal(t, base, geoCountForLabel(t, `!\!weird`))
|
||||
})
|
||||
t.Run("LegacyAmpersandName", func(t *testing.T) {
|
||||
assert.Equal(t, 1, geoCountForLabel(t, `construction\&failure`))
|
||||
// Unescaped now parses as two positive AND groups, neither resolvable.
|
||||
assert.Equal(t, 0, geoCountForLabel(t, "construction&failure"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPhotosGeoQueryLabel(t *testing.T) {
|
||||
t.Run("SingleExclude", func(t *testing.T) {
|
||||
base := geoBaselineCount(t)
|
||||
withFlower := geoCountForLabel(t, "flower")
|
||||
|
||||
q := form.NewSearchPhotosGeo(`label:"!flower"`)
|
||||
result, err := PhotosGeo(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.Equal(t, base-withFlower, len(result))
|
||||
})
|
||||
t.Run("IncludeAndExclude", func(t *testing.T) {
|
||||
q := form.NewSearchPhotosGeo(`label:"cake&flower"`)
|
||||
result, err := PhotosGeo(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, len(result))
|
||||
})
|
||||
}
|
||||
@@ -72,7 +72,7 @@ type SearchPhotos struct {
|
||||
Diff uint32 `form:"diff" notes:"Differential Perceptual Hash (000000-FFFFFF)"`
|
||||
Geo string `form:"geo" example:"geo:yes" notes:"Finds content with or without latitude and longitude"`
|
||||
Keywords string `form:"keywords" example:"keywords:\"sand&water\"" notes:"Keywords, combinable with & and |"`
|
||||
Label string `form:"label" example:"label:cat|dog" notes:"Label names, separated by |"`
|
||||
Label string `form:"label" example:"label:\"cat|dog&!blurry\"" notes:"Label names: | is OR within a group, & is AND between groups (every positive group must match), leading ! negates a group (e.g. !rejected). Category expansion applies to both positive and negative terms. Escape a literal &, |, or leading ! with \\"`
|
||||
Category string `form:"category" example:"category:airport" notes:"Location category type"`
|
||||
Country string `form:"country" example:"country:\"de|us\"" notes:"Country codes, separated by |"` // Moments
|
||||
State string `form:"state" example:"state:\"Baden-Württemberg\"" notes:"State or province names, separated by |"` // Moments
|
||||
|
||||
+2
-2
@@ -80,8 +80,8 @@ func SearchString(s string) string {
|
||||
s = strings.ReplaceAll(s, "%", "*")
|
||||
s = strings.ReplaceAll(s, "**", "*")
|
||||
|
||||
// Trim.
|
||||
return strings.Trim(s, "|\\<>\n\r\t")
|
||||
// Trim — keep '\' so downstream filters can honor escape sequences.
|
||||
return strings.Trim(s, "|<>\n\r\t")
|
||||
}
|
||||
|
||||
// SearchQuery replaces search operator with default symbols.
|
||||
|
||||
Reference in New Issue
Block a user