1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
16
17
18
19 package search
20
21 import (
22 "bytes"
23 "context"
24 "encoding/json"
25 "errors"
26 "fmt"
27 "regexp"
28 "strconv"
29 "strings"
30 "time"
31
32 "go4.org/types"
33 "perkeep.org/internal/geocode"
34 "perkeep.org/pkg/schema/nodeattr"
35 )
36
37 const base = "0000-01-01T00:00:00Z"
38
39 var (
40
41
42
43 whRangeExpr = regexp.MustCompile(`^(\d{0,10})-(\d{0,10})$`)
44 whValueExpr = regexp.MustCompile(`^(\d{1,10})$`)
45 )
46
47
48
49
50 type atom struct {
51 predicate string
52 args []string
53 }
54
55 func (a atom) String() string {
56 s := bytes.NewBufferString(a.predicate)
57 for _, a := range a.args {
58 s.WriteRune(':')
59 s.WriteString(a)
60 }
61 return s.String()
62 }
63
64
65 type keyword interface {
66
67 Name() string
68
69
70 Description() string
71
72
73
74 Match(a atom) (bool, error)
75
76
77
78
79 Predicate(ctx context.Context, args []string) (*Constraint, error)
80 }
81
82 var keywords []keyword
83
84
85
86 func registerKeyword(k keyword) {
87 keywords = append(keywords, k)
88 }
89
90
91 func SearchHelp() string {
92 type help struct{ Name, Description string }
93 h := []help{}
94 for _, p := range keywords {
95 h = append(h, help{p.Name(), p.Description()})
96 }
97 b, err := json.MarshalIndent(h, "", " ")
98 if err != nil {
99 return "Error marshalling"
100 }
101 return string(b)
102 }
103
104 func init() {
105
106 registerKeyword(newAfter())
107 registerKeyword(newBefore())
108 registerKeyword(newAttribute())
109 registerKeyword(newChildrenOf())
110 registerKeyword(newParentOf())
111 registerKeyword(newFormat())
112 registerKeyword(newTag())
113 registerKeyword(newTitle())
114 registerKeyword(newRef())
115
116
117 registerKeyword(newIsImage())
118 registerKeyword(newHeight())
119 registerKeyword(newIsLandscape())
120 registerKeyword(newIsPano())
121 registerKeyword(newIsPortait())
122 registerKeyword(newWidth())
123
124
125 registerKeyword(newFilename())
126
127
128 registerKeyword(newIsPost())
129 registerKeyword(newIsLike())
130 registerKeyword(newIsCheckin())
131 registerKeyword(newIsUntagged())
132
133
134 registerKeyword(newHasLocation())
135 registerKeyword(newNamedLocation())
136 registerKeyword(newLocation())
137
138
139 registerKeyword(newWith())
140 }
141
142
143
144 type matchEqual string
145
146 func (me matchEqual) Name() string {
147 return string(me)
148 }
149
150 func (me matchEqual) Match(a atom) (bool, error) {
151 return string(me) == a.String(), nil
152 }
153
154
155
156
157 type matchPrefix struct {
158 prefix string
159 count int
160 }
161
162 func newMatchPrefix(p string) matchPrefix {
163 return matchPrefix{prefix: p, count: 1}
164 }
165
166 func (mp matchPrefix) Name() string {
167 return mp.prefix
168 }
169 func (mp matchPrefix) Match(a atom) (bool, error) {
170 if mp.prefix == a.predicate {
171 if len(a.args) != mp.count {
172 return true, fmt.Errorf("Wrong number of arguments for %q, given %d, expected %d", mp.prefix, len(a.args), mp.count)
173 }
174 return true, nil
175 }
176 return false, nil
177 }
178
179
180
181 type after struct {
182 matchPrefix
183 }
184
185 func newAfter() keyword {
186 return after{newMatchPrefix("after")}
187 }
188
189 func (a after) Description() string {
190 return "date format is RFC3339, but can be shortened as required.\n" +
191 "i.e. 2011-01-01 is Jan 1 of year 2011 and \"2011\" means the same."
192 }
193
194 func (a after) Predicate(ctx context.Context, args []string) (*Constraint, error) {
195 t, err := parseTimePrefix(args[0])
196 if err != nil {
197 return nil, err
198 }
199 tc := &TimeConstraint{}
200 tc.After = types.Time3339(t)
201 c := &Constraint{
202 Permanode: &PermanodeConstraint{
203 Time: tc,
204 },
205 }
206 return c, nil
207 }
208
209 type before struct {
210 matchPrefix
211 }
212
213 func newBefore() keyword {
214 return before{newMatchPrefix("before")}
215 }
216
217 func (b before) Description() string {
218 return "date format is RFC3339, but can be shortened as required.\n" +
219 "i.e. 2011-01-01 is Jan 1 of year 2011 and \"2011\" means the same."
220 }
221
222 func (b before) Predicate(ctx context.Context, args []string) (*Constraint, error) {
223 t, err := parseTimePrefix(args[0])
224 if err != nil {
225 return nil, err
226 }
227 tc := &TimeConstraint{}
228 tc.Before = types.Time3339(t)
229 c := &Constraint{
230 Permanode: &PermanodeConstraint{
231 Time: tc,
232 },
233 }
234 return c, nil
235 }
236
237 type attribute struct {
238 matchPrefix
239 }
240
241 func newAttribute() keyword {
242 return attribute{matchPrefix{"attr", 2}}
243 }
244
245 func (a attribute) Description() string {
246 return "match on attribute. Use attr:foo:bar to match nodes having their foo\n" +
247 "attribute set to bar or attr:foo:~bar to do a substring\n" +
248 "case-insensitive search for 'bar' in attribute foo"
249 }
250
251 func (a attribute) Predicate(ctx context.Context, args []string) (*Constraint, error) {
252 c := permWithAttr(args[0], args[1])
253 if strings.HasPrefix(args[1], "~") {
254
255 c.Permanode.Value = ""
256 c.Permanode.ValueMatches = &StringConstraint{
257 Contains: args[1][1:],
258 CaseInsensitive: true,
259 }
260 }
261 return c, nil
262 }
263
264 type childrenOf struct {
265 matchPrefix
266 }
267
268 func newChildrenOf() keyword {
269 return childrenOf{newMatchPrefix("childrenof")}
270 }
271
272 func (k childrenOf) Description() string {
273 return "Find child permanodes of a parent permanode (or prefix of a parent\n" +
274 "permanode): childrenof:sha1-527cf12 Only matches permanodes currently."
275 }
276
277 func (k childrenOf) Predicate(ctx context.Context, args []string) (*Constraint, error) {
278 c := &Constraint{
279 Permanode: &PermanodeConstraint{
280 Relation: &RelationConstraint{
281 Relation: "parent",
282 Any: &Constraint{
283 BlobRefPrefix: args[0],
284 },
285 },
286 },
287 }
288 return c, nil
289 }
290
291 type parentOf struct {
292 matchPrefix
293 }
294
295 func newParentOf() keyword {
296 return parentOf{newMatchPrefix("parentof")}
297 }
298
299 func (k parentOf) Description() string {
300 return "Find parent permanodes of a child permanode (or prefix of a child\n" +
301 "permanode): parentof:sha1-527cf12 Only matches permanodes currently."
302 }
303
304 func (k parentOf) Predicate(ctx context.Context, args []string) (*Constraint, error) {
305 c := &Constraint{
306 Permanode: &PermanodeConstraint{
307 Relation: &RelationConstraint{
308 Relation: "child",
309 Any: &Constraint{
310 BlobRefPrefix: args[0],
311 },
312 },
313 },
314 }
315 return c, nil
316 }
317
318 type format struct {
319 matchPrefix
320 }
321
322 func newFormat() keyword {
323 return format{newMatchPrefix("format")}
324 }
325
326 func (f format) Description() string {
327 return "file's format (or MIME-type) such as jpg, pdf, tiff."
328 }
329
330 func (f format) Predicate(ctx context.Context, args []string) (*Constraint, error) {
331 mimeType, err := mimeFromFormat(args[0])
332 if err != nil {
333 return nil, err
334 }
335 c := permOfFile(&FileConstraint{
336 MIMEType: &StringConstraint{
337 Equals: mimeType,
338 },
339 })
340 return c, nil
341 }
342
343 type tag struct {
344 matchPrefix
345 }
346
347 func newTag() keyword {
348 return tag{newMatchPrefix("tag")}
349 }
350
351 func (t tag) Description() string {
352 return "match on a tag"
353 }
354
355 func (t tag) Predicate(ctx context.Context, args []string) (*Constraint, error) {
356 return permWithAttr("tag", args[0]), nil
357 }
358
359 type with struct {
360 matchPrefix
361 }
362
363 func newWith() keyword {
364 return with{newMatchPrefix("with")}
365 }
366
367 func (w with) Description() string {
368 return "match people containing substring in their first or last name"
369 }
370
371 func (w with) Predicate(ctx context.Context, args []string) (*Constraint, error) {
372
373 c := &Constraint{
374
375
376
377 Permanode: &PermanodeConstraint{
378 Attr: "with",
379 ValueInSet: andConst(
380 &Constraint{
381 Permanode: &PermanodeConstraint{
382 Attr: nodeattr.Type,
383 Value: "foursquare.com:person",
384 },
385 },
386 orConst(
387 permWithAttrSubstr(nodeattr.GivenName, &StringConstraint{
388 Contains: args[0],
389 CaseInsensitive: true,
390 }),
391 permWithAttrSubstr(nodeattr.FamilyName, &StringConstraint{
392 Contains: args[0],
393 CaseInsensitive: true,
394 }),
395 ),
396 ),
397 },
398 }
399 return c, nil
400 }
401
402 type title struct {
403 matchPrefix
404 }
405
406 func newTitle() keyword {
407 return title{newMatchPrefix("title")}
408 }
409
410 func (t title) Description() string {
411 return "match nodes containing substring in their title"
412 }
413
414 func (t title) Predicate(ctx context.Context, args []string) (*Constraint, error) {
415 c := &Constraint{
416 Permanode: &PermanodeConstraint{
417 Attr: nodeattr.Title,
418 SkipHidden: true,
419 ValueMatches: &StringConstraint{
420 Contains: args[0],
421 CaseInsensitive: true,
422 },
423 },
424 }
425 return c, nil
426 }
427
428 type ref struct {
429 matchPrefix
430 }
431
432 func newRef() keyword {
433 return ref{newMatchPrefix("ref")}
434 }
435
436 func (r ref) Description() string {
437 return "match nodes whose blobRef starts with the given substring"
438 }
439
440 func (r ref) Predicate(ctx context.Context, args []string) (*Constraint, error) {
441 return &Constraint{
442 BlobRefPrefix: args[0],
443 }, nil
444 }
445
446
447
448 type isImage struct {
449 matchEqual
450 }
451
452 func newIsImage() keyword {
453 return isImage{"is:image"}
454 }
455
456 func (k isImage) Description() string {
457 return "object is an image"
458 }
459
460 func (k isImage) Predicate(ctx context.Context, args []string) (*Constraint, error) {
461 c := &Constraint{
462 Permanode: &PermanodeConstraint{
463 Attr: nodeattr.CamliContent,
464 ValueInSet: &Constraint{
465 File: &FileConstraint{
466 IsImage: true,
467 },
468 },
469 },
470 }
471 return c, nil
472 }
473
474 type isLandscape struct {
475 matchEqual
476 }
477
478 func newIsLandscape() keyword {
479 return isLandscape{"is:landscape"}
480 }
481
482 func (k isLandscape) Description() string {
483 return "the image has a landscape aspect"
484 }
485
486 func (k isLandscape) Predicate(ctx context.Context, args []string) (*Constraint, error) {
487 return whRatio(&FloatConstraint{Min: 1.0}), nil
488 }
489
490 type isPano struct {
491 matchEqual
492 }
493
494 func newIsPano() keyword {
495 return isPano{"is:pano"}
496 }
497
498 func (k isPano) Description() string {
499 return "the image's aspect ratio is over 2 - panorama picture."
500 }
501
502 func (k isPano) Predicate(ctx context.Context, args []string) (*Constraint, error) {
503 return whRatio(&FloatConstraint{Min: 2.0}), nil
504 }
505
506 type isPortait struct {
507 matchEqual
508 }
509
510 func newIsPortait() keyword {
511 return isPortait{"is:portrait"}
512 }
513
514 func (k isPortait) Description() string {
515 return "the image has a portrait aspect"
516 }
517
518 func (k isPortait) Predicate(ctx context.Context, args []string) (*Constraint, error) {
519 return whRatio(&FloatConstraint{Max: 1.0}), nil
520 }
521
522 type width struct {
523 matchPrefix
524 }
525
526 func newWidth() keyword {
527 return width{newMatchPrefix("width")}
528 }
529
530 func (w width) Description() string {
531 return "use width:min-max to match images having a width of at least min\n" +
532 "and at most max. Use width:min- to specify only an underbound and\n" +
533 "width:-max to specify only an upperbound.\n" +
534 "Exact matches should use width:640 "
535 }
536
537 func (w width) Predicate(ctx context.Context, args []string) (*Constraint, error) {
538 mins, maxs, err := parseWHExpression(args[0])
539 if err != nil {
540 return nil, err
541 }
542 c := permOfFile(&FileConstraint{
543 IsImage: true,
544 Width: whIntConstraint(mins, maxs),
545 })
546 return c, nil
547 }
548
549 type height struct {
550 matchPrefix
551 }
552
553 func newHeight() keyword {
554 return height{newMatchPrefix("height")}
555 }
556
557 func (h height) Description() string {
558 return "use height:min-max to match images having a height of at least min\n" +
559 "and at most max. Use height:min- to specify only an underbound and\n" +
560 "height:-max to specify only an upperbound.\n" +
561 "Exact matches should use height:480"
562 }
563
564 func (h height) Predicate(ctx context.Context, args []string) (*Constraint, error) {
565 mins, maxs, err := parseWHExpression(args[0])
566 if err != nil {
567 return nil, err
568 }
569 c := permOfFile(&FileConstraint{
570 IsImage: true,
571 Height: whIntConstraint(mins, maxs),
572 })
573 return c, nil
574 }
575
576
577
578
579 type namedLocation struct {
580 matchPrefix
581 }
582
583 func newNamedLocation() keyword {
584 return namedLocation{newMatchPrefix("loc")}
585 }
586
587 func (l namedLocation) Description() string {
588 return "matches images and permanodes having a location near\n" +
589 "the specified location. Locations are resolved using\n" +
590 "maps.googleapis.com. For example: loc:\"new york, new york\" "
591 }
592
593 func locationPredicate(ctx context.Context, rects []geocode.Rect) (*Constraint, error) {
594 var c *Constraint
595 for i, rect := range rects {
596 loc := &LocationConstraint{
597 West: rect.SouthWest.Long,
598 East: rect.NorthEast.Long,
599 North: rect.NorthEast.Lat,
600 South: rect.SouthWest.Lat,
601 }
602 permLoc := &Constraint{
603 Permanode: &PermanodeConstraint{
604 Location: loc,
605 },
606 }
607 if i == 0 {
608 c = permLoc
609 } else {
610 c = orConst(c, permLoc)
611 }
612 }
613 return c, nil
614 }
615
616 func (l namedLocation) Predicate(ctx context.Context, args []string) (*Constraint, error) {
617 where := args[0]
618 rects, err := geocode.Lookup(ctx, where)
619 if err != nil {
620 return nil, err
621 }
622 if len(rects) == 0 {
623 return nil, fmt.Errorf("No location found for %q", where)
624 }
625 return locationPredicate(ctx, rects)
626 }
627
628
629 type location struct {
630 matchPrefix
631 }
632
633 func newLocation() keyword {
634 return location{newMatchPrefix("locrect")}
635 }
636
637 func (l location) Description() string {
638 return "matches images and permanodes having a location within\n" +
639 "the specified location area. The area is defined by its\n " +
640 "North-West corner, followed and comma-separated by its\n " +
641 "South-East corner. Each corner is defined by its latitude,\n " +
642 "followed and comma-separated by its longitude."
643 }
644
645 func (l location) Predicate(ctx context.Context, args []string) (*Constraint, error) {
646 where := args[0]
647 coords := strings.Split(where, ",")
648 if len(coords) != 4 {
649 return nil, fmt.Errorf("got %d coordinates for location area, expected 4", len(coords))
650 }
651 asFloat := make([]float64, 4)
652 for k, v := range coords {
653 coo, err := strconv.ParseFloat(v, 64)
654 if err != nil {
655 return nil, fmt.Errorf("could not convert location area coordinate as a float: %v", err)
656 }
657 asFloat[k] = coo
658 }
659 rects := []geocode.Rect{
660 {
661 NorthEast: geocode.LatLong{
662 Lat: asFloat[0],
663 Long: asFloat[3],
664 },
665 SouthWest: geocode.LatLong{
666 Lat: asFloat[2],
667 Long: asFloat[1],
668 },
669 },
670 }
671 return locationPredicate(ctx, rects)
672 }
673
674 type hasLocation struct {
675 matchEqual
676 }
677
678 func newHasLocation() keyword {
679 return hasLocation{"has:location"}
680 }
681
682 func (h hasLocation) Description() string {
683 return "matches images and permanodes that have a location (GPSLatitude\n" +
684 "and GPSLongitude can be retrieved from the image's EXIF tags)."
685 }
686
687 func (h hasLocation) Predicate(ctx context.Context, args []string) (*Constraint, error) {
688 return &Constraint{
689 Permanode: &PermanodeConstraint{
690 Location: &LocationConstraint{
691 Any: true,
692 },
693 },
694 }, nil
695 }
696
697
698 type namedSearch struct {
699 matchPrefix
700 sh *Handler
701 }
702
703 func newNamedSearch(sh *Handler) keyword {
704 return namedSearch{newMatchPrefix("named"), sh}
705 }
706
707 func (n namedSearch) Description() string {
708 return "Uses substitution of a predefined search. Set with $searchRoot/camli/search/setnamed?name=foo&substitute=attr:bar:baz" +
709 "\nSee what the substitute is with $searchRoot/camli/search/getnamed?named=foo"
710 }
711
712 func (n namedSearch) Predicate(ctx context.Context, args []string) (*Constraint, error) {
713 return n.namedConstraint(args[0])
714 }
715
716 func (n namedSearch) namedConstraint(name string) (*Constraint, error) {
717 subst, err := n.sh.getNamed(context.TODO(), name)
718 if err != nil {
719 return nil, err
720 }
721 return evalSearchInput(subst)
722 }
723
724
725
726 func permWithAttr(attr, val string) *Constraint {
727 c := &Constraint{
728 Permanode: &PermanodeConstraint{
729 Attr: attr,
730 SkipHidden: true,
731 },
732 }
733 if val == "" {
734 c.Permanode.ValueMatches = &StringConstraint{Empty: true}
735 } else {
736 c.Permanode.Value = val
737 }
738 return c
739 }
740
741 func permWithAttrSubstr(attr string, c *StringConstraint) *Constraint {
742 return &Constraint{
743 Permanode: &PermanodeConstraint{
744 Attr: attr,
745 ValueMatches: c,
746 },
747 }
748 }
749
750 func permOfFile(fc *FileConstraint) *Constraint {
751 return &Constraint{
752 Permanode: &PermanodeConstraint{
753 Attr: nodeattr.CamliContent,
754 ValueInSet: &Constraint{File: fc},
755 },
756 }
757 }
758
759 func whRatio(fc *FloatConstraint) *Constraint {
760 return permOfFile(&FileConstraint{
761 IsImage: true,
762 WHRatio: fc,
763 })
764 }
765
766 func parseWHExpression(expr string) (min, max string, err error) {
767 if m := whRangeExpr.FindStringSubmatch(expr); m != nil {
768 return m[1], m[2], nil
769 }
770 if m := whValueExpr.FindStringSubmatch(expr); m != nil {
771 return m[1], m[1], nil
772 }
773 return "", "", fmt.Errorf("Unable to parse %q as range, wanted something like 480-1024, 480-, -1024 or 1024", expr)
774 }
775
776 func parseTimePrefix(when string) (time.Time, error) {
777 if len(when) < len(base) {
778 when += base[len(when):]
779 }
780 return time.Parse(time.RFC3339, when)
781 }
782
783 func whIntConstraint(mins, maxs string) *IntConstraint {
784 ic := &IntConstraint{}
785 if mins != "" {
786 if mins == "0" {
787 ic.ZeroMin = true
788 } else {
789 n, _ := strconv.Atoi(mins)
790 ic.Min = int64(n)
791 }
792 }
793 if maxs != "" {
794 if maxs == "0" {
795 ic.ZeroMax = true
796 } else {
797 n, _ := strconv.Atoi(maxs)
798 ic.Max = int64(n)
799 }
800 }
801 return ic
802 }
803
804 func mimeFromFormat(v string) (string, error) {
805 if strings.Contains(v, "/") {
806 return v, nil
807 }
808 switch v {
809 case "jpg", "jpeg":
810 return "image/jpeg", nil
811 case "gif":
812 return "image/gif", nil
813 case "png":
814 return "image/png", nil
815 case "pdf":
816 return "application/pdf", nil
817 }
818 return "", fmt.Errorf("Unknown format: %s", v)
819 }
820
821
822
823 type isPost struct {
824 matchEqual
825 }
826
827 func newIsPost() keyword {
828 return isPost{"is:post"}
829 }
830
831 func (k isPost) Description() string {
832 return "matches tweets, status updates, blog posts, etc"
833 }
834
835 func (k isPost) Predicate(ctx context.Context, args []string) (*Constraint, error) {
836 return &Constraint{
837 Permanode: &PermanodeConstraint{
838 Attr: nodeattr.Type,
839 Value: "twitter.com:tweet",
840 },
841 }, nil
842 }
843
844 type isLike struct {
845 matchEqual
846 }
847
848 func newIsLike() keyword {
849 return isLike{"is:like"}
850 }
851
852 func (k isLike) Description() string {
853 return "matches liked tweets"
854 }
855
856 func (k isLike) Predicate(ctx context.Context, args []string) (*Constraint, error) {
857 return &Constraint{
858 Permanode: &PermanodeConstraint{
859 Attr: nodeattr.Type,
860 Value: "twitter.com:like",
861 },
862 }, nil
863 }
864
865 type isCheckin struct {
866 matchEqual
867 }
868
869 func newIsCheckin() keyword {
870 return isCheckin{"is:checkin"}
871 }
872
873 func (k isCheckin) Description() string {
874 return "matches location check-ins (foursquare, etc)"
875 }
876
877 func (k isCheckin) Predicate(ctx context.Context, args []string) (*Constraint, error) {
878 return &Constraint{
879 Permanode: &PermanodeConstraint{
880 Attr: nodeattr.Type,
881 Value: "foursquare.com:checkin",
882 },
883 }, nil
884 }
885
886 type isUntagged struct {
887 matchEqual
888 }
889
890 func newIsUntagged() keyword {
891 return isUntagged{"is:untagged"}
892 }
893
894 func (k isUntagged) Description() string {
895 return "matches untagged permanodes"
896 }
897
898 func (k isUntagged) Predicate(ctx context.Context, args []string) (*Constraint, error) {
899 return &Constraint{
900
901
902 Logical: &LogicalConstraint{
903 Op: "not",
904 A: &Constraint{
905 Permanode: &PermanodeConstraint{
906 Attr: "tag",
907 SkipHidden: true,
908 ValueMatches: &StringConstraint{
909 ByteLength: &IntConstraint{
910 Min: 1,
911 },
912 },
913 },
914 },
915 },
916 }, nil
917 }
918
919 type filename struct {
920 matchPrefix
921 }
922
923 func newFilename() keyword {
924 return filename{newMatchPrefix("filename")}
925 }
926
927 func (fn filename) Description() string {
928 return "Match filename, case sensitively. Supports optional '*' wildcard at beginning, end, or both."
929 }
930
931 func (fn filename) Predicate(ctx context.Context, args []string) (*Constraint, error) {
932 arg := args[0]
933 switch {
934 case !strings.Contains(arg, "*"):
935 return permOfFile(&FileConstraint{FileName: &StringConstraint{Equals: arg}}), nil
936 case strings.HasPrefix(arg, "*") && !strings.Contains(arg[1:], "*"):
937 suffix := arg[1:]
938 return permOfFile(&FileConstraint{FileName: &StringConstraint{HasSuffix: suffix}}), nil
939 case strings.HasSuffix(arg, "*") && !strings.Contains(arg[:len(arg)-1], "*"):
940 prefix := arg[:len(arg)-1]
941 return permOfFile(&FileConstraint{FileName: &StringConstraint{
942 HasPrefix: prefix,
943 }}), nil
944 case strings.HasSuffix(arg, "*") && strings.HasPrefix(arg, "*") && !strings.Contains(arg[1:len(arg)-1], "*"):
945 sub := arg[1 : len(arg)-1]
946 return permOfFile(&FileConstraint{FileName: &StringConstraint{Contains: sub}}), nil
947 }
948 return nil, errors.New("unsupported glob wildcard in filename search predicate")
949 }