Search: Add NOT & AND operators to label filter #5535

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2026-04-17 17:25:18 +02:00
parent 15036786bc
commit 51976b7f5c
8 changed files with 592 additions and 17 deletions
+152
View File
@@ -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
}
+149
View File
@@ -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)
})
}
+19 -4
View File
@@ -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))
})
}
+11 -4
View File
@@ -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))
})
}
+1 -1
View File
@@ -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
View File
@@ -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.