mirror of https://github.com/perkeep/perkeep.git
1324 lines
36 KiB
Go
1324 lines
36 KiB
Go
/*
|
|
Copyright 2013 The Camlistore Authors
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package search
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"camlistore.org/pkg/blob"
|
|
"camlistore.org/pkg/context"
|
|
"camlistore.org/pkg/index"
|
|
"camlistore.org/pkg/strutil"
|
|
"camlistore.org/pkg/types"
|
|
"camlistore.org/pkg/types/camtypes"
|
|
)
|
|
|
|
type SortType int
|
|
|
|
const (
|
|
UnspecifiedSort SortType = iota
|
|
LastModifiedDesc
|
|
LastModifiedAsc
|
|
CreatedDesc
|
|
CreatedAsc
|
|
maxSortType
|
|
)
|
|
|
|
var sortName = map[SortType][]byte{
|
|
LastModifiedDesc: []byte(`"-mod"`),
|
|
LastModifiedAsc: []byte(`"mod"`),
|
|
CreatedDesc: []byte(`"-created"`),
|
|
CreatedAsc: []byte(`"created"`),
|
|
}
|
|
|
|
func (t SortType) MarshalJSON() ([]byte, error) {
|
|
v, ok := sortName[t]
|
|
if !ok {
|
|
panic("unnamed SortType " + strconv.Itoa(int(t)))
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (t *SortType) UnmarshalJSON(v []byte) error {
|
|
for n, nv := range sortName {
|
|
if bytes.Equal(v, nv) {
|
|
*t = n
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("Bogus search sort type %q", v)
|
|
}
|
|
|
|
type SearchQuery struct {
|
|
// Exactly one of Expression or Contraint must be set.
|
|
// If an Expression is set, it's compiled to an Constraint.
|
|
|
|
// Expression is a textual search query in minimal form,
|
|
// e.g. "hawaii before:2008" or "tag:foo" or "foo" or "location:portland"
|
|
// See expr.go and expr_test.go for all the operators.
|
|
Expression string `json:"expression,omitempty"`
|
|
Constraint *Constraint `json:"constraint,omitempty"`
|
|
|
|
Limit int `json:"limit,omitempty"` // optional. default is automatic. negative means no limit.
|
|
Sort SortType `json:"sort,omitempty"` // optional. default is automatic or unsorted.
|
|
|
|
// Continue specifies the opaque token (as returned by a
|
|
// SearchResult) for where to continue fetching results when
|
|
// the Limit on a previous query was interrupted.
|
|
// Continue is only valid for the same query (Expression or Constraint),
|
|
// Limit, and Sort values.
|
|
// If empty, the top-most query results are returned, as given
|
|
// by Limit and Sort.
|
|
Continue string `json:"continue,omitempty"`
|
|
|
|
// If Describe is specified, the matched blobs are also described,
|
|
// as if the Describe.BlobRefs field was populated.
|
|
Describe *DescribeRequest `json:"describe,omitempty"`
|
|
}
|
|
|
|
func (q *SearchQuery) URLSuffix() string { return "camli/search/query" }
|
|
|
|
func (q *SearchQuery) fromHTTP(req *http.Request) error {
|
|
dec := json.NewDecoder(io.LimitReader(req.Body, 1<<20))
|
|
if err := dec.Decode(q); err != nil {
|
|
return err
|
|
}
|
|
|
|
if q.Constraint == nil && q.Expression == "" {
|
|
return errors.New("query must have at least a constraint or an expression")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// exprQuery optionally specifies the *SearchQuery prototype that was generated
|
|
// by parsing the search expression
|
|
func (q *SearchQuery) plannedQuery(expr *SearchQuery) *SearchQuery {
|
|
pq := new(SearchQuery)
|
|
*pq = *q
|
|
if expr != nil {
|
|
pq.Constraint = expr.Constraint
|
|
if expr.Sort != 0 {
|
|
pq.Sort = expr.Sort
|
|
}
|
|
if expr.Limit != 0 {
|
|
pq.Limit = expr.Limit
|
|
}
|
|
}
|
|
if pq.Sort == 0 {
|
|
if pq.Constraint.onlyMatchesPermanode() {
|
|
pq.Sort = LastModifiedDesc // TODO: CreatedDesc
|
|
}
|
|
}
|
|
if pq.Limit == 0 {
|
|
pq.Limit = 200 // arbitrary
|
|
}
|
|
if err := pq.addContinueConstraint(); err != nil {
|
|
log.Printf("Ignoring continue token: %v", err)
|
|
}
|
|
pq.Constraint = optimizePlan(pq.Constraint)
|
|
return pq
|
|
}
|
|
|
|
// For permanodes, the continue token is (currently!)
|
|
// of form "pn:nnnnnnn:sha1-xxxxx" where "pn" is a
|
|
// literal, "nnnnnn" is the UnixNano of the time
|
|
// (modified or created) and "sha1-xxxxx" was the item
|
|
// seen in the final result set, used as a tie breaker
|
|
// if multiple permanodes had the same mod/created
|
|
// time. This format is NOT an API promise or standard and
|
|
// clients should not rely on it. It may change without notice
|
|
func parsePermanodeContinueToken(v string) (t time.Time, br blob.Ref, ok bool) {
|
|
if !strings.HasPrefix(v, "pn:") {
|
|
return
|
|
}
|
|
v = v[len("pn:"):]
|
|
col := strings.Index(v, ":")
|
|
if col < 0 {
|
|
return
|
|
}
|
|
nano, err := strconv.ParseUint(v[:col], 10, 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
t = time.Unix(0, int64(nano))
|
|
br, ok = blob.Parse(v[col+1:])
|
|
return
|
|
}
|
|
|
|
// addContinueConstraint conditionally modifies q.Constraint to scroll
|
|
// past the results as indicated by q.Continue.
|
|
func (q *SearchQuery) addContinueConstraint() error {
|
|
cont := q.Continue
|
|
if cont == "" {
|
|
return nil
|
|
}
|
|
if q.Constraint.onlyMatchesPermanode() {
|
|
tokent, lastbr, ok := parsePermanodeContinueToken(cont)
|
|
if !ok {
|
|
return errors.New("Unexpected continue token")
|
|
}
|
|
if q.Sort == LastModifiedDesc { // TODO: || q.Sort == CreatedDesc {
|
|
baseConstraint := q.Constraint
|
|
q.Constraint = &Constraint{
|
|
Logical: &LogicalConstraint{
|
|
Op: "and",
|
|
A: &Constraint{
|
|
Permanode: &PermanodeConstraint{
|
|
Continue: &PermanodeContinueConstraint{
|
|
LastMod: tokent,
|
|
Last: lastbr,
|
|
},
|
|
},
|
|
},
|
|
B: baseConstraint,
|
|
},
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return errors.New("token not valid for query type")
|
|
}
|
|
|
|
func (q *SearchQuery) checkValid(ctx *context.Context) (sq *SearchQuery, err error) {
|
|
if q.Limit < 0 {
|
|
return nil, errors.New("negative limit")
|
|
}
|
|
if q.Sort >= maxSortType || q.Sort < 0 {
|
|
return nil, errors.New("invalid sort type")
|
|
}
|
|
if q.Constraint == nil {
|
|
if expr := q.Expression; expr != "" {
|
|
sq, err := parseExpression(ctx, expr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Error parsing search expression %q: %v", expr, err)
|
|
}
|
|
if err := sq.Constraint.checkValid(); err != nil {
|
|
log.Fatalf("Internal error: parseExpression(%q) returned invalid constraint: %v", expr, err)
|
|
}
|
|
return sq, nil
|
|
}
|
|
return nil, errors.New("no search constraint or expression")
|
|
}
|
|
return nil, q.Constraint.checkValid()
|
|
}
|
|
|
|
// SearchResult is the result of the Search method for a given SearchQuery.
|
|
type SearchResult struct {
|
|
Blobs []*SearchResultBlob `json:"blobs"`
|
|
Describe *DescribeResponse `json:"description"`
|
|
|
|
// Continue optionally specifies the continuation token to to
|
|
// continue fetching results in this result set, if interrupted
|
|
// by a Limit.
|
|
Continue string `json:"continue,omitempty"`
|
|
}
|
|
|
|
type SearchResultBlob struct {
|
|
Blob blob.Ref `json:"blob"`
|
|
// ... file info, permanode info, blob info ... ?
|
|
}
|
|
|
|
func (r *SearchResultBlob) String() string {
|
|
return fmt.Sprintf("[blob: %s]", r.Blob)
|
|
}
|
|
|
|
// Constraint specifies a blob matching constraint.
|
|
// A blob matches if it matches all non-zero fields' predicates.
|
|
// A zero constraint matches nothing.
|
|
type Constraint struct {
|
|
// If Logical is non-nil, all other fields are ignored.
|
|
Logical *LogicalConstraint `json:"logical,omitempty"`
|
|
|
|
// Anything, if true, matches all blobs.
|
|
Anything bool `json:"anything,omitempty"`
|
|
|
|
CamliType string `json:"camliType,omitempty"` // camliType of the JSON blob
|
|
AnyCamliType bool `json:"anyCamliType,omitempty"` // if true, any camli JSON blob matches
|
|
BlobRefPrefix string `json:"blobRefPrefix,omitempty"`
|
|
|
|
File *FileConstraint `json:"file,omitempty"`
|
|
Dir *DirConstraint `json:"dir,omitempty"`
|
|
|
|
Claim *ClaimConstraint `json:"claim,omitempty"`
|
|
BlobSize *IntConstraint `json:"blobSize,omitempty"`
|
|
|
|
Permanode *PermanodeConstraint `json:"permanode,omitempty"`
|
|
|
|
matcherOnce sync.Once
|
|
matcherFn matchFn
|
|
}
|
|
|
|
func (c *Constraint) checkValid() error {
|
|
type checker interface {
|
|
checkValid() error
|
|
}
|
|
if c.Claim != nil {
|
|
return errors.New("TODO: implement ClaimConstraint")
|
|
}
|
|
for _, cv := range []checker{
|
|
c.Logical,
|
|
c.File,
|
|
c.Dir,
|
|
c.BlobSize,
|
|
c.Permanode,
|
|
} {
|
|
if err := cv.checkValid(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Constraint) onlyMatchesPermanode() bool {
|
|
if c.Permanode != nil || c.CamliType == "permanode" {
|
|
return true
|
|
}
|
|
|
|
if c.Logical != nil && c.Logical.Op == "and" {
|
|
if c.Logical.A.onlyMatchesPermanode() || c.Logical.B.onlyMatchesPermanode() {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// TODO: There are other cases we can return true here, like:
|
|
// Logical:{Op:'or', A:PermanodeConstraint{...}, B:PermanodeConstraint{...}
|
|
|
|
return false
|
|
}
|
|
|
|
type FileConstraint struct {
|
|
// (All non-zero fields must match)
|
|
|
|
FileSize *IntConstraint `json:"fileSize,omitempty"`
|
|
FileName *StringConstraint
|
|
MIMEType *StringConstraint
|
|
Time *TimeConstraint
|
|
ModTime *TimeConstraint
|
|
|
|
// For images:
|
|
IsImage bool `json:"isImage,omitempty"`
|
|
EXIF *EXIFConstraint `json:"exif,omitempty"` // TODO: implement
|
|
Width *IntConstraint `json:"width,omitempty"`
|
|
Height *IntConstraint `json:"height,omitempty"`
|
|
WHRatio *FloatConstraint `json:"widthHeightRation,omitempty"`
|
|
Location *LocationConstraint `json:"location,omitempty"`
|
|
|
|
// MediaTag is for ID3 (and similar) embedded metadata in files.
|
|
MediaTag *MediaTagConstraint `json:"mediaTag,omitempty"`
|
|
}
|
|
|
|
type MediaTagConstraint struct {
|
|
// Tag is the tag to match.
|
|
// For ID3, this includes: title, artist, album, genre, year, track, disc, mediaref, durationms.
|
|
Tag string `json:"tag"`
|
|
|
|
String *StringConstraint `json:"string,omitempty"`
|
|
Int *IntConstraint `json:"int,omitempty"`
|
|
}
|
|
|
|
type DirConstraint struct {
|
|
// (All non-zero fields must match)
|
|
|
|
// TODO: implement. mostly need more things in the index.
|
|
|
|
FileName *StringConstraint
|
|
|
|
TopFileSize, // not recursive
|
|
TopFileCount, // not recursive
|
|
FileSize,
|
|
FileCount *IntConstraint
|
|
|
|
// TODO: these would need thought on how to index efficiently:
|
|
// (Also: top-only variants?)
|
|
// ContainsFile *FileConstraint
|
|
// ContainsDir *DirConstraint
|
|
}
|
|
|
|
// An IntConstraint specifies constraints on an integer.
|
|
type IntConstraint struct {
|
|
// Min and Max are both optional and inclusive bounds.
|
|
// Zero means don't check.
|
|
Min int64 `json:"min,omitempty"`
|
|
Max int64 `json:"max,omitempty"`
|
|
ZeroMin bool `json:"zeroMin,omitempty"` // if true, min is actually zero
|
|
ZeroMax bool `json:"zeroMax,omitempty"` // if true, max is actually zero
|
|
}
|
|
|
|
func (c *IntConstraint) hasMin() bool { return c.Min != 0 || c.ZeroMin }
|
|
func (c *IntConstraint) hasMax() bool { return c.Max != 0 || c.ZeroMax }
|
|
|
|
func (c *IntConstraint) checkValid() error {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
if c.ZeroMin && c.Min != 0 {
|
|
return errors.New("in IntConstraint, can't set both ZeroMin and Min")
|
|
}
|
|
if c.ZeroMax && c.Max != 0 {
|
|
return errors.New("in IntConstraint, can't set both ZeroMax and Max")
|
|
}
|
|
if c.hasMax() && c.hasMin() && c.Min > c.Max {
|
|
return errors.New("in IntConstraint, min is greater than max")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *IntConstraint) intMatches(v int64) bool {
|
|
if c.hasMin() && v < c.Min {
|
|
return false
|
|
}
|
|
if c.hasMax() && v > c.Max {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// A FloatConstraint specifies constraints on a float.
|
|
type FloatConstraint struct {
|
|
// Min and Max are both optional and inclusive bounds.
|
|
// Zero means don't check.
|
|
Min float64 `json:"min,omitempty"`
|
|
Max float64 `json:"max,omitempty"`
|
|
ZeroMin bool `json:"zeroMin,omitempty"` // if true, min is actually zero
|
|
ZeroMax bool `json:"zeroMax,omitempty"` // if true, max is actually zero
|
|
}
|
|
|
|
func (c *FloatConstraint) hasMin() bool { return c.Min != 0 || c.ZeroMin }
|
|
func (c *FloatConstraint) hasMax() bool { return c.Max != 0 || c.ZeroMax }
|
|
|
|
func (c *FloatConstraint) checkValid() error {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
if c.ZeroMin && c.Min != 0 {
|
|
return errors.New("in FloatConstraint, can't set both ZeroMin and Min")
|
|
}
|
|
if c.ZeroMax && c.Max != 0 {
|
|
return errors.New("in FloatConstraint, can't set both ZeroMax and Max")
|
|
}
|
|
if c.hasMax() && c.hasMin() && c.Min > c.Max {
|
|
return errors.New("in FloatConstraint, min is greater than max")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *FloatConstraint) floatMatches(v float64) bool {
|
|
if c.hasMin() && v < c.Min {
|
|
return false
|
|
}
|
|
if c.hasMax() && v > c.Max {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
type EXIFConstraint struct {
|
|
// TODO. need to put this in the index probably.
|
|
// Maybe: GPS *LocationConstraint
|
|
// ISO, Aperature, Camera Make/Model, etc.
|
|
}
|
|
|
|
type LocationConstraint struct {
|
|
// Any, if true, matches any photo with a known location.
|
|
Any bool
|
|
|
|
// North, West, East, and South define a region in which a photo
|
|
// must be in order to match.
|
|
North float64
|
|
West float64
|
|
East float64
|
|
South float64
|
|
}
|
|
|
|
func (c *LocationConstraint) matchesLatLong(lat, long float64) bool {
|
|
return c.West <= long && long <= c.East && c.South <= lat && lat <= c.North
|
|
}
|
|
|
|
// A StringConstraint specifies constraints on a string.
|
|
// All non-zero must match.
|
|
type StringConstraint struct {
|
|
Empty bool `json:"empty,omitempty"` // matches empty string
|
|
Equals string `json:"equals,omitempty"`
|
|
Contains string `json:"contains,omitempty"`
|
|
HasPrefix string `json:"hasPrefix,omitempty"`
|
|
HasSuffix string `json:"hasSuffix,omitempty"`
|
|
ByteLength *IntConstraint `json:"byteLength,omitempty"` // length in bytes (not chars)
|
|
CaseInsensitive bool `json:"caseInsensitive,omitempty"`
|
|
|
|
// TODO: CharLength (assume UTF-8)
|
|
}
|
|
|
|
// stringCompareFunc contains a function to get a value from a StringConstraint and a second function to compare it
|
|
// against the string s that's being matched.
|
|
type stringConstraintFunc struct {
|
|
v func(*StringConstraint) string
|
|
fn func(s, v string) bool
|
|
}
|
|
|
|
// Functions to compare fields of a StringConstraint against strings in a case-sensitive manner.
|
|
var stringConstraintFuncs = []stringConstraintFunc{
|
|
{func(c *StringConstraint) string { return c.Equals }, func(a, b string) bool { return a == b }},
|
|
{func(c *StringConstraint) string { return c.Contains }, strings.Contains},
|
|
{func(c *StringConstraint) string { return c.HasPrefix }, strings.HasPrefix},
|
|
{func(c *StringConstraint) string { return c.HasSuffix }, strings.HasSuffix},
|
|
}
|
|
|
|
// Functions to compare fields of a StringConstraint against strings in a case-insensitive manner.
|
|
var stringConstraintFuncsFold = []stringConstraintFunc{
|
|
{func(c *StringConstraint) string { return c.Equals }, strings.EqualFold},
|
|
{func(c *StringConstraint) string { return c.Contains }, strutil.ContainsFold},
|
|
{func(c *StringConstraint) string { return c.HasPrefix }, strutil.HasPrefixFold},
|
|
{func(c *StringConstraint) string { return c.HasSuffix }, strutil.HasSuffixFold},
|
|
}
|
|
|
|
func (c *StringConstraint) stringMatches(s string) bool {
|
|
if c.Empty && len(s) > 0 {
|
|
return false
|
|
}
|
|
if c.ByteLength != nil && !c.ByteLength.intMatches(int64(len(s))) {
|
|
return false
|
|
}
|
|
|
|
funcs := stringConstraintFuncs
|
|
if c.CaseInsensitive {
|
|
funcs = stringConstraintFuncsFold
|
|
}
|
|
for _, pair := range funcs {
|
|
if v := pair.v(c); v != "" && !pair.fn(s, v) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type TimeConstraint struct {
|
|
Before types.Time3339 `json:"before"` // <
|
|
After types.Time3339 `json:"after"` // >=
|
|
|
|
// TODO: this won't JSON-marshal/unmarshal well. Make a time.Duration marshal type?
|
|
// Likewise with time that supports omitempty?
|
|
InLast time.Duration `json:"inLast"` // >=
|
|
}
|
|
|
|
type ClaimConstraint struct {
|
|
SignedBy string `json:"signedBy"` // identity
|
|
SignedAfter time.Time `json:"signedAfter"`
|
|
SignedBefore time.Time `json:"signedBefore"`
|
|
}
|
|
|
|
func (c *ClaimConstraint) checkValid() error {
|
|
return errors.New("TODO: implement blobMatches and checkValid on ClaimConstraint")
|
|
}
|
|
|
|
type LogicalConstraint struct {
|
|
Op string `json:"op"` // "and", "or", "xor", "not"
|
|
A *Constraint `json:"a"`
|
|
B *Constraint `json:"b"` // only valid if Op != "not"
|
|
}
|
|
|
|
// PermanodeConstraint matches permanodes.
|
|
type PermanodeConstraint struct {
|
|
// At specifies the time at which to pretend we're resolving attributes.
|
|
// Attribute claims after this point in time are ignored.
|
|
// If zero, the current time is used.
|
|
At time.Time `json:"at,omitempty"`
|
|
|
|
// ModTime optionally matches on the last modtime of the permanode.
|
|
ModTime *TimeConstraint `json:"modTime,omitempty"`
|
|
|
|
// Time optionally matches the permanode's time. A Permanode
|
|
// may not have a known time. If the permanode does not have a
|
|
// known time, one may be guessed if the top-level search
|
|
// parameters request so.
|
|
Time *TimeConstraint `json:"time,omitempty"`
|
|
|
|
// Attr optionally specifies the attribute to match.
|
|
// e.g. "camliContent", "camliMember", "tag"
|
|
// This is required if any of the items below are used.
|
|
Attr string `json:"attr,omitempty"`
|
|
|
|
// SkipHidden skips hidden or other boring files.
|
|
SkipHidden bool `json:"skipHidden,omitempty"`
|
|
|
|
// NumValue optionally tests the number of values this
|
|
// permanode has for Attr.
|
|
NumValue *IntConstraint `json:"numValue,omitempty"`
|
|
|
|
// ValueAll modifies the matching behavior when an attribute
|
|
// is multi-valued. By default, when ValueAll is false, only
|
|
// one value of a multi-valued attribute needs to match. If
|
|
// ValueAll is true, all attributes must match.
|
|
ValueAll bool `json:"valueAllMatch,omitempty"`
|
|
|
|
// Value specifies an exact string to match.
|
|
// This is a convenience form for the simple case of exact
|
|
// equality. The same can be accomplished with ValueMatches.
|
|
Value string `json:"value,omitempty"` // if non-zero, absolute match
|
|
|
|
// ValueMatches optionally specifies a StringConstraint to
|
|
// match the value against.
|
|
ValueMatches *StringConstraint `json:"valueMatches,omitempty"`
|
|
|
|
// ValueMatchesInt optionally specifies an IntConstraint to match
|
|
// the value against. Non-integer values will not match.
|
|
ValueMatchesInt *IntConstraint `json:"valueMatchesInt,omitempty"`
|
|
|
|
// ValueMatchesFloat optionally specifies a FloatConstraint to match
|
|
// the value against. Non-float values will not match.
|
|
ValueMatchesFloat *FloatConstraint `json:"valueMatchesFloat,omitempty"`
|
|
|
|
// ValueInSet optionally specifies a sub-query which the value
|
|
// (which must be a blobref) must be a part of.
|
|
ValueInSet *Constraint `json:"valueInSet,omitempty"`
|
|
|
|
// Relation optionally specifies a constraint based on relations
|
|
// to other permanodes (e.g. camliMember or camliPath sets).
|
|
// You can use it to test the properties of a parent, ancestor,
|
|
// child, or progeny.
|
|
Relation *RelationConstraint `json:"relation,omitempty"`
|
|
|
|
// Continue is for internal use.
|
|
Continue *PermanodeContinueConstraint `json:"-"`
|
|
|
|
// TODO:
|
|
// NumClaims *IntConstraint // by owner
|
|
// Owner blob.Ref // search for permanodes by an owner
|
|
|
|
// Note: When adding a field, update hasValueConstraint.
|
|
}
|
|
|
|
type PermanodeContinueConstraint struct {
|
|
// LastMod if non-zero is the modtime of the last item
|
|
// that was seen. One of this or LastCreated will be set.
|
|
LastMod time.Time
|
|
|
|
// TODO: LastCreated time.Time
|
|
|
|
// Last is the last blobref that was shown at the time
|
|
// given in ModLessEqual or CreateLessEqual.
|
|
// This is used as a tie-breaker.
|
|
// If the time is equal, permanodes <= this are not matched.
|
|
// If the time is past this in the scroll position, then this
|
|
// field is ignored.
|
|
Last blob.Ref
|
|
}
|
|
|
|
type RelationConstraint struct {
|
|
// Relation must be one of:
|
|
//
|
|
// * "child"
|
|
// * "progeny" (any level down)
|
|
// * "parent" (immediate parent only)
|
|
// * "ancestor" (any level up)
|
|
Relation string
|
|
|
|
// EdgeType optionally specifies an edge type.
|
|
// By default it matches "camliMember" and "camliPath:*".
|
|
EdgeType string
|
|
|
|
// After finding all the nodes matching the Relation and
|
|
// EdgeType, either one or all (depending on whether Any or
|
|
// All is set) must then match for the RelationConstraint
|
|
// itself to match.
|
|
//
|
|
// It is an error to set both.
|
|
Any, All *Constraint
|
|
}
|
|
|
|
// search is the state of an in-progress search
|
|
type search struct {
|
|
h *Handler
|
|
q *SearchQuery
|
|
res *SearchResult
|
|
ctx *context.Context
|
|
|
|
// ss is a scratch string slice to avoid allocations.
|
|
// We assume (at least so far) that only 1 goroutine is used
|
|
// for a given search, so anything can use this.
|
|
ss []string // scratch
|
|
}
|
|
|
|
func (s *search) blobMeta(br blob.Ref) (camtypes.BlobMeta, error) {
|
|
if c := s.h.corpus; c != nil {
|
|
return c.GetBlobMetaLocked(br)
|
|
} else {
|
|
return s.h.index.GetBlobMeta(br)
|
|
}
|
|
}
|
|
|
|
func (s *search) fileInfo(br blob.Ref) (camtypes.FileInfo, error) {
|
|
if c := s.h.corpus; c != nil {
|
|
return c.GetFileInfoLocked(br)
|
|
} else {
|
|
return s.h.index.GetFileInfo(br)
|
|
}
|
|
}
|
|
|
|
// optimizePlan returns an optimized version of c which will hopefully
|
|
// execute faster than executing c literally.
|
|
func optimizePlan(c *Constraint) *Constraint {
|
|
// TODO: what the comment above says.
|
|
return c
|
|
}
|
|
|
|
func (h *Handler) Query(rawq *SearchQuery) (*SearchResult, error) {
|
|
ctx := context.TODO() // TODO: set from rawq
|
|
exprResult, err := rawq.checkValid(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Invalid SearchQuery: %v", err)
|
|
}
|
|
q := rawq.plannedQuery(exprResult)
|
|
res := new(SearchResult)
|
|
s := &search{
|
|
h: h,
|
|
q: q,
|
|
res: res,
|
|
ctx: context.TODO(),
|
|
}
|
|
defer s.ctx.Cancel()
|
|
|
|
corpus := h.corpus
|
|
var unlockOnce sync.Once
|
|
if corpus != nil {
|
|
corpus.RLock()
|
|
defer unlockOnce.Do(corpus.RUnlock)
|
|
}
|
|
|
|
ch := make(chan camtypes.BlobMeta, buffered)
|
|
errc := make(chan error, 1)
|
|
|
|
cands := q.pickCandidateSource(s)
|
|
if candSourceHook != nil {
|
|
candSourceHook(cands.name)
|
|
}
|
|
|
|
sendCtx := s.ctx.New()
|
|
defer sendCtx.Cancel()
|
|
go func() { errc <- cands.send(sendCtx, s, ch) }()
|
|
|
|
blobMatches := q.Constraint.matcher()
|
|
for meta := range ch {
|
|
match, err := blobMatches(s, meta.Ref, meta)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if match {
|
|
res.Blobs = append(res.Blobs, &SearchResultBlob{
|
|
Blob: meta.Ref,
|
|
})
|
|
if q.Limit > 0 && len(res.Blobs) == q.Limit && cands.sorted {
|
|
sendCtx.Cancel()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if err := <-errc; err != nil && err != context.ErrCanceled {
|
|
return nil, err
|
|
}
|
|
if !cands.sorted {
|
|
// TODO(bradfitz): sort them
|
|
if q.Limit > 0 && len(res.Blobs) > q.Limit {
|
|
res.Blobs = res.Blobs[:q.Limit]
|
|
}
|
|
}
|
|
if corpus != nil {
|
|
q.setResultContinue(corpus, res)
|
|
unlockOnce.Do(corpus.RUnlock)
|
|
}
|
|
|
|
if q.Describe != nil {
|
|
q.Describe.BlobRef = blob.Ref{} // zero this out, if caller set it
|
|
blobs := make([]blob.Ref, 0, len(res.Blobs))
|
|
for _, srb := range res.Blobs {
|
|
blobs = append(blobs, srb.Blob)
|
|
}
|
|
q.Describe.BlobRefs = blobs
|
|
res, err := s.h.Describe(q.Describe)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.res.Describe = res
|
|
}
|
|
return s.res, nil
|
|
}
|
|
|
|
// setResultContinue sets res.Continue if q is suitable for having a continue token.
|
|
// The corpus is locked for reads.
|
|
func (q *SearchQuery) setResultContinue(corpus *index.Corpus, res *SearchResult) {
|
|
if !q.Constraint.onlyMatchesPermanode() {
|
|
return
|
|
}
|
|
if q.Sort != LastModifiedDesc {
|
|
// Unsupported so far.
|
|
return
|
|
}
|
|
if q.Limit <= 0 || len(res.Blobs) != q.Limit {
|
|
return
|
|
}
|
|
lastpn := res.Blobs[len(res.Blobs)-1].Blob
|
|
t, ok := corpus.PermanodeModtimeLocked(lastpn)
|
|
if !ok {
|
|
return
|
|
}
|
|
res.Continue = fmt.Sprintf("pn:%d:%v", t.UnixNano(), lastpn)
|
|
}
|
|
|
|
const camliTypeMIME = "application/json; camliType="
|
|
|
|
type matchFn func(*search, blob.Ref, camtypes.BlobMeta) (bool, error)
|
|
|
|
func alwaysMatch(*search, blob.Ref, camtypes.BlobMeta) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
func neverMatch(*search, blob.Ref, camtypes.BlobMeta) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
func anyCamliType(s *search, br blob.Ref, bm camtypes.BlobMeta) (bool, error) {
|
|
return bm.CamliType != "", nil
|
|
}
|
|
|
|
// Test hook.
|
|
var candSourceHook func(string)
|
|
|
|
type candidateSource struct {
|
|
name string
|
|
sorted bool
|
|
|
|
// sends sends to the channel and must close it, regardless of error
|
|
// or interruption from context.Done().
|
|
send func(*context.Context, *search, chan<- camtypes.BlobMeta) error
|
|
}
|
|
|
|
func (q *SearchQuery) pickCandidateSource(s *search) (src candidateSource) {
|
|
c := q.Constraint
|
|
corpus := s.h.corpus
|
|
if corpus != nil {
|
|
if c.onlyMatchesPermanode() {
|
|
if q.Sort == LastModifiedDesc {
|
|
src.sorted = true
|
|
src.name = "corpus_permanode_lastmod"
|
|
src.send = func(ctx *context.Context, s *search, dst chan<- camtypes.BlobMeta) error {
|
|
return corpus.EnumeratePermanodesLastModifiedLocked(ctx, dst)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
if c.AnyCamliType || c.CamliType != "" {
|
|
camType := c.CamliType // empty means all
|
|
src.name = "corpus_blob_meta"
|
|
src.send = func(ctx *context.Context, s *search, dst chan<- camtypes.BlobMeta) error {
|
|
return corpus.EnumerateCamliBlobsLocked(ctx, camType, dst)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
src.name = "index_blob_meta"
|
|
src.send = func(ctx *context.Context, s *search, dst chan<- camtypes.BlobMeta) error {
|
|
return s.h.index.EnumerateBlobMeta(ctx, dst)
|
|
}
|
|
return
|
|
}
|
|
|
|
type allMustMatch []matchFn
|
|
|
|
func (fns allMustMatch) blobMatches(s *search, br blob.Ref, blobMeta camtypes.BlobMeta) (bool, error) {
|
|
for _, condFn := range fns {
|
|
match, err := condFn(s, br, blobMeta)
|
|
if !match || err != nil {
|
|
return match, err
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (c *Constraint) matcher() func(s *search, br blob.Ref, blobMeta camtypes.BlobMeta) (bool, error) {
|
|
c.matcherOnce.Do(c.initMatcherFn)
|
|
return c.matcherFn
|
|
}
|
|
|
|
func (c *Constraint) initMatcherFn() {
|
|
c.matcherFn = c.genMatcher()
|
|
}
|
|
|
|
func (c *Constraint) genMatcher() matchFn {
|
|
var ncond int
|
|
var cond matchFn
|
|
var conds []matchFn
|
|
addCond := func(fn matchFn) {
|
|
ncond++
|
|
if ncond == 1 {
|
|
cond = fn
|
|
return
|
|
} else if ncond == 2 {
|
|
conds = append(conds, cond)
|
|
}
|
|
conds = append(conds, fn)
|
|
}
|
|
if c.Logical != nil {
|
|
addCond(c.Logical.matcher())
|
|
}
|
|
if c.Anything {
|
|
addCond(alwaysMatch)
|
|
}
|
|
if c.CamliType != "" {
|
|
addCond(func(s *search, br blob.Ref, bm camtypes.BlobMeta) (bool, error) {
|
|
return bm.CamliType == c.CamliType, nil
|
|
})
|
|
}
|
|
if c.AnyCamliType {
|
|
addCond(anyCamliType)
|
|
}
|
|
if c.Permanode != nil {
|
|
addCond(c.Permanode.blobMatches)
|
|
}
|
|
// TODO: ClaimConstraint
|
|
if c.File != nil {
|
|
addCond(c.File.blobMatches)
|
|
}
|
|
if c.Dir != nil {
|
|
addCond(c.Dir.blobMatches)
|
|
}
|
|
if bs := c.BlobSize; bs != nil {
|
|
addCond(func(s *search, br blob.Ref, bm camtypes.BlobMeta) (bool, error) {
|
|
return bs.intMatches(int64(bm.Size)), nil
|
|
})
|
|
}
|
|
if pfx := c.BlobRefPrefix; pfx != "" {
|
|
addCond(func(s *search, br blob.Ref, meta camtypes.BlobMeta) (bool, error) {
|
|
return strings.HasPrefix(br.String(), pfx), nil
|
|
})
|
|
}
|
|
switch ncond {
|
|
case 0:
|
|
return neverMatch
|
|
case 1:
|
|
return cond
|
|
default:
|
|
return allMustMatch(conds).blobMatches
|
|
}
|
|
}
|
|
|
|
func (c *LogicalConstraint) checkValid() error {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
if c.A == nil {
|
|
return errors.New("In LogicalConstraint, need to set A")
|
|
}
|
|
if err := c.A.checkValid(); err != nil {
|
|
return err
|
|
}
|
|
switch c.Op {
|
|
case "and", "xor", "or":
|
|
if c.B == nil {
|
|
return errors.New("In LogicalConstraint, need both A and B set")
|
|
}
|
|
if err := c.B.checkValid(); err != nil {
|
|
return err
|
|
}
|
|
case "not":
|
|
default:
|
|
return fmt.Errorf("In LogicalConstraint, unknown operation %q", c.Op)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *LogicalConstraint) matcher() matchFn {
|
|
amatches := c.A.matcher()
|
|
var bmatches matchFn
|
|
if c.Op != "not" {
|
|
bmatches = c.B.matcher()
|
|
}
|
|
return func(s *search, br blob.Ref, bm camtypes.BlobMeta) (bool, error) {
|
|
|
|
// Note: not using multiple goroutines here, because
|
|
// so far the *search type assumes it's
|
|
// single-threaded. (e.g. the .ss scratch type).
|
|
// Also, not using multiple goroutines means we can
|
|
// short-circuit when Op == "and" and av is false.
|
|
|
|
av, err := amatches(s, br, bm)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
switch c.Op {
|
|
case "not":
|
|
return !av, nil
|
|
case "and":
|
|
if !av {
|
|
// Short-circuit.
|
|
return false, nil
|
|
}
|
|
case "or":
|
|
if av {
|
|
// Short-circuit.
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
bv, err := bmatches(s, br, bm)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
switch c.Op {
|
|
case "and", "or":
|
|
return bv, nil
|
|
case "xor":
|
|
return av != bv, nil
|
|
}
|
|
panic("unreachable")
|
|
}
|
|
}
|
|
|
|
func (c *PermanodeConstraint) checkValid() error {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
if c.Attr != "" {
|
|
if c.NumValue == nil && !c.hasValueConstraint() {
|
|
return errors.New("PermanodeConstraint with Attr requires also setting NumValue or a value-matching constraint")
|
|
}
|
|
if nv := c.NumValue; nv != nil {
|
|
if nv.ZeroMin {
|
|
return errors.New("NumValue with ZeroMin makes no sense; matches everything")
|
|
}
|
|
if nv.ZeroMax && c.hasValueConstraint() {
|
|
return errors.New("NumValue with ZeroMax makes no sense in conjunction with a value-matching constraint; matches nothing")
|
|
}
|
|
if nv.Min < 0 || nv.Max < 0 {
|
|
return errors.New("NumValue with negative Min or Max makes no sense")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var numPermanodeFields = reflect.TypeOf(PermanodeConstraint{}).NumField()
|
|
|
|
// hasValueConstraint returns true if one or more constraints that check an attribute's value are set.
|
|
func (c *PermanodeConstraint) hasValueConstraint() bool {
|
|
// If a field has been added or removed, update this after adding the new field to the return statement if necessary.
|
|
const expectedFields = 14
|
|
if numPermanodeFields != expectedFields {
|
|
panic(fmt.Sprintf("PermanodeConstraint field count changed (now %v rather than %v)", numPermanodeFields, expectedFields))
|
|
}
|
|
return c.Value != "" ||
|
|
c.ValueMatches != nil ||
|
|
c.ValueMatchesInt != nil ||
|
|
c.ValueMatchesFloat != nil ||
|
|
c.ValueInSet != nil
|
|
}
|
|
|
|
func (c *PermanodeConstraint) blobMatches(s *search, br blob.Ref, bm camtypes.BlobMeta) (ok bool, err error) {
|
|
if bm.CamliType != "permanode" {
|
|
return false, nil
|
|
}
|
|
corpus := s.h.corpus
|
|
|
|
var dp *DescribedPermanode
|
|
if corpus == nil {
|
|
dr, err := s.h.Describe(&DescribeRequest{BlobRef: br})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
db := dr.Meta[br.String()]
|
|
if db == nil || db.Permanode == nil {
|
|
return false, nil
|
|
}
|
|
dp = db.Permanode
|
|
}
|
|
|
|
if c.Attr != "" {
|
|
if !c.At.IsZero() && corpus == nil {
|
|
panic("PermanodeConstraint.At not supported without an in-memory corpus")
|
|
}
|
|
var vals []string
|
|
if corpus == nil {
|
|
vals = dp.Attr[c.Attr]
|
|
} else {
|
|
s.ss = corpus.AppendPermanodeAttrValuesLocked(
|
|
s.ss[:0], br, c.Attr, c.At, s.h.owner)
|
|
vals = s.ss
|
|
}
|
|
ok, err := c.permanodeMatchesAttrVals(s, vals)
|
|
if !ok || err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
if c.SkipHidden && corpus != nil {
|
|
vals := corpus.AppendPermanodeAttrValuesLocked(s.ss[:0], br, "camliDefVis", time.Time{}, s.h.owner)
|
|
for _, v := range vals {
|
|
if v == "hide" {
|
|
return false, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if c.ModTime != nil {
|
|
if corpus != nil {
|
|
mt, ok := corpus.PermanodeModtimeLocked(br)
|
|
if !ok || !c.ModTime.timeMatches(mt) {
|
|
return false, nil
|
|
}
|
|
} else if !c.ModTime.timeMatches(dp.ModTime) {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
if c.Time != nil {
|
|
if corpus != nil {
|
|
t, ok := corpus.PermanodeTimeLocked(br)
|
|
if !ok || !c.Time.timeMatches(t) {
|
|
return false, nil
|
|
}
|
|
} else {
|
|
panic("TODO: not yet supported")
|
|
}
|
|
}
|
|
|
|
if cc := c.Continue; cc != nil {
|
|
if corpus == nil {
|
|
// Requires an in-memory index for infinite
|
|
// scroll. At least for now.
|
|
return false, nil
|
|
}
|
|
if !cc.LastMod.IsZero() {
|
|
mt, ok := corpus.PermanodeModtimeLocked(br)
|
|
if !ok || mt.After(cc.LastMod) {
|
|
return false, nil
|
|
}
|
|
// Blobs are sorted by modtime, and then by
|
|
// blobref, and then reversed overall. From
|
|
// top of page, imagining this scenario, where
|
|
// the user requested a page size Limit of 4:
|
|
// mod5, sha1-25
|
|
// mod4, sha1-72
|
|
// mod3, sha1-cc
|
|
// mod3, sha1-bb <--- last seen ite, continue = "pn:mod3:sha1-bb"
|
|
// mod3, sha1-aa <-- and we want this one next.
|
|
// In the case above, we'll see all of cc, bb, and cc for mod3.
|
|
if mt.Equal(cc.LastMod) && !br.Less(cc.Last) {
|
|
return false, nil
|
|
}
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// vals are the current permanode values of c.Attr.
|
|
func (c *PermanodeConstraint) permanodeMatchesAttrVals(s *search, vals []string) (bool, error) {
|
|
if c.NumValue != nil && !c.NumValue.intMatches(int64(len(vals))) {
|
|
return false, nil
|
|
}
|
|
if c.hasValueConstraint() {
|
|
nmatch := 0
|
|
for _, val := range vals {
|
|
match, err := c.permanodeMatchesAttrVal(s, val)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if match {
|
|
nmatch++
|
|
}
|
|
}
|
|
if nmatch == 0 {
|
|
return false, nil
|
|
}
|
|
if c.ValueAll {
|
|
return nmatch == len(vals), nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (c *PermanodeConstraint) permanodeMatchesAttrVal(s *search, val string) (bool, error) {
|
|
if c.Value != "" && c.Value != val {
|
|
return false, nil
|
|
}
|
|
if c.ValueMatches != nil && !c.ValueMatches.stringMatches(val) {
|
|
return false, nil
|
|
}
|
|
if c.ValueMatchesInt != nil {
|
|
if i, err := strconv.ParseInt(val, 10, 64); err != nil || !c.ValueMatchesInt.intMatches(i) {
|
|
return false, nil
|
|
}
|
|
}
|
|
if c.ValueMatchesFloat != nil {
|
|
if f, err := strconv.ParseFloat(val, 64); err != nil || !c.ValueMatchesFloat.floatMatches(f) {
|
|
return false, nil
|
|
}
|
|
}
|
|
if subc := c.ValueInSet; subc != nil {
|
|
br, ok := blob.Parse(val) // TODO: use corpus's parse, or keep this as blob.Ref in corpus attr
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
meta, err := s.blobMeta(br)
|
|
if err == os.ErrNotExist {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return subc.matcher()(s, br, meta)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (c *FileConstraint) checkValid() error {
|
|
return nil
|
|
}
|
|
|
|
func (c *FileConstraint) blobMatches(s *search, br blob.Ref, bm camtypes.BlobMeta) (bool, error) {
|
|
if bm.CamliType != "file" {
|
|
return false, nil
|
|
}
|
|
fi, err := s.fileInfo(br)
|
|
if err == os.ErrNotExist {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if fs := c.FileSize; fs != nil && !fs.intMatches(fi.Size) {
|
|
return false, nil
|
|
}
|
|
if c.IsImage && !strings.HasPrefix(fi.MIMEType, "image/") {
|
|
return false, nil
|
|
}
|
|
if sc := c.FileName; sc != nil && !sc.stringMatches(fi.FileName) {
|
|
return false, nil
|
|
}
|
|
if sc := c.MIMEType; sc != nil && !sc.stringMatches(fi.MIMEType) {
|
|
return false, nil
|
|
}
|
|
if tc := c.Time; tc != nil {
|
|
if fi.Time == nil || !tc.timeMatches(fi.Time.Time()) {
|
|
return false, nil
|
|
}
|
|
}
|
|
if tc := c.ModTime; tc != nil {
|
|
if fi.ModTime == nil || !tc.timeMatches(fi.ModTime.Time()) {
|
|
return false, nil
|
|
}
|
|
}
|
|
corpus := s.h.corpus
|
|
var width, height int64
|
|
if c.Width != nil || c.Height != nil || c.WHRatio != nil {
|
|
if corpus == nil {
|
|
return false, nil
|
|
}
|
|
imageInfo, err := corpus.GetImageInfoLocked(br)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
width = int64(imageInfo.Width)
|
|
height = int64(imageInfo.Height)
|
|
}
|
|
if c.Width != nil && !c.Width.intMatches(width) {
|
|
return false, nil
|
|
}
|
|
if c.Height != nil && !c.Height.intMatches(height) {
|
|
return false, nil
|
|
}
|
|
if c.WHRatio != nil && !c.WHRatio.floatMatches(float64(width)/float64(height)) {
|
|
return false, nil
|
|
}
|
|
if c.Location != nil {
|
|
if corpus == nil {
|
|
return false, nil
|
|
}
|
|
lat, long, ok := corpus.FileLatLongLocked(br)
|
|
if ok && c.Location.Any {
|
|
// Pass.
|
|
} else if !ok || !c.Location.matchesLatLong(lat, long) {
|
|
return false, nil
|
|
}
|
|
}
|
|
if mt := c.MediaTag; mt != nil {
|
|
if corpus == nil {
|
|
return false, nil
|
|
}
|
|
mediaTags := corpus.MediaTagLocked(br)
|
|
var tagValue string
|
|
if mediaTags != nil && mt.Tag != "" {
|
|
tagValue = mediaTags[mt.Tag]
|
|
}
|
|
if mt.Int != nil {
|
|
if i, err := strconv.ParseInt(tagValue, 10, 64); err != nil || !mt.Int.intMatches(i) {
|
|
return false, nil
|
|
}
|
|
}
|
|
if mt.String != nil && !mt.String.stringMatches(tagValue) {
|
|
return false, nil
|
|
}
|
|
}
|
|
// TOOD: EXIF timeconstraint
|
|
return true, nil
|
|
}
|
|
|
|
func (c *TimeConstraint) timeMatches(t time.Time) bool {
|
|
if t.IsZero() {
|
|
return false
|
|
}
|
|
if !c.Before.IsZero() {
|
|
if !t.Before(time.Time(c.Before)) {
|
|
return false
|
|
}
|
|
}
|
|
after := time.Time(c.After)
|
|
if after.IsZero() && c.InLast > 0 {
|
|
after = time.Now().Add(-c.InLast)
|
|
}
|
|
if !after.IsZero() {
|
|
if !(t.Equal(after) || t.After(after)) { // after is >=
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (c *DirConstraint) checkValid() error {
|
|
return nil
|
|
}
|
|
|
|
func (c *DirConstraint) blobMatches(s *search, br blob.Ref, bm camtypes.BlobMeta) (bool, error) {
|
|
if bm.CamliType != "directory" {
|
|
return false, nil
|
|
}
|
|
|
|
// TODO: implement
|
|
panic("TODO: implement DirConstraint.blobMatches")
|
|
}
|