mirror of
https://git.sr.ht/~rjarry/aerc
synced 2026-03-02 18:23:33 +01:00
search: use a common api for all workers
Define a SearchCriteria structure. Update the FetchDirectoryContents, FetchDirectoryThreaded and SearchDirectory worker messages to include this SearchCriteria structure instead of a []string slice. Parse the search arguments in a single place into a SearchCriteria structure and use it to search/filter via the message store. Update all workers to use that new API. Clarify the man page indicating that notmuch supports searching with aerc's syntax and also with notmuch specific syntax. getopt is no longer needed, remove it from go.mod. NB: to support more complex search filters in JMAP, we need to use an email.Filter interface. Since GOB does not support encoding/decoding interfaces, store the raw SearchCriteria and []SortCriterion values in the cached FolderContents. Translate them to JMAP API objects when sending an email.Query request to the server. Signed-off-by: Robin Jarry <robin@jarry.cc> Reviewed-by: Koni Marti <koni.marti@gmail.com> Tested-by: Moritz Poldrack <moritz@poldrack.dev> Tested-by: Inwit <inwit@sindominio.net>
This commit is contained in:
@@ -2,18 +2,35 @@ package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/parse"
|
||||
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
"git.sr.ht/~rjarry/aerc/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type SearchFilter struct {
|
||||
Unused struct{} `opt:"-"`
|
||||
Read bool `opt:"-r" action:"ParseRead"`
|
||||
Unread bool `opt:"-u" action:"ParseUnread"`
|
||||
Body bool `opt:"-b"`
|
||||
All bool `opt:"-a"`
|
||||
Headers textproto.MIMEHeader `opt:"-H" action:"ParseHeader" metavar:"<header>:<value>"`
|
||||
WithFlags models.Flags `opt:"-x" action:"ParseFlag"`
|
||||
WithoutFlags models.Flags `opt:"-X" action:"ParseNotFlag"`
|
||||
To []string `opt:"-t" action:"ParseTo"`
|
||||
From []string `opt:"-f" action:"ParseFrom"`
|
||||
Cc []string `opt:"-c" action:"ParseCc"`
|
||||
StartDate time.Time `opt:"-d" action:"ParseDate"`
|
||||
EndDate time.Time
|
||||
Terms string `opt:"..." required:"false"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -49,7 +66,82 @@ func (SearchFilter) Complete(args []string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (SearchFilter) Execute(args []string) error {
|
||||
func (s *SearchFilter) ParseRead(arg string) error {
|
||||
s.WithFlags |= models.SeenFlag
|
||||
s.WithoutFlags &^= models.SeenFlag
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseUnread(arg string) error {
|
||||
s.WithFlags &^= models.SeenFlag
|
||||
s.WithoutFlags |= models.SeenFlag
|
||||
return nil
|
||||
}
|
||||
|
||||
var flagValues = map[string]models.Flags{
|
||||
"seen": models.SeenFlag,
|
||||
"answered": models.AnsweredFlag,
|
||||
"flagged": models.FlaggedFlag,
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseFlag(arg string) error {
|
||||
f, ok := flagValues[strings.ToLower(arg)]
|
||||
if !ok {
|
||||
return fmt.Errorf("%q unknown flag", arg)
|
||||
}
|
||||
s.WithFlags |= f
|
||||
s.WithoutFlags &^= f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseNotFlag(arg string) error {
|
||||
f, ok := flagValues[strings.ToLower(arg)]
|
||||
if !ok {
|
||||
return fmt.Errorf("%q unknown flag", arg)
|
||||
}
|
||||
s.WithFlags &^= f
|
||||
s.WithoutFlags |= f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseHeader(arg string) error {
|
||||
name, value, hasColon := strings.Cut(arg, ":")
|
||||
if !hasColon {
|
||||
return fmt.Errorf("%q invalid syntax", arg)
|
||||
}
|
||||
if s.Headers == nil {
|
||||
s.Headers = make(textproto.MIMEHeader)
|
||||
}
|
||||
s.Headers.Add(name, strings.TrimSpace(value))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseTo(arg string) error {
|
||||
s.To = append(s.To, arg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseFrom(arg string) error {
|
||||
s.From = append(s.From, arg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseCc(arg string) error {
|
||||
s.Cc = append(s.Cc, arg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseDate(arg string) error {
|
||||
start, end, err := parse.DateRange(arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.StartDate = start
|
||||
s.EndDate = end
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s SearchFilter) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
@@ -59,12 +151,26 @@ func (SearchFilter) Execute(args []string) error {
|
||||
return errors.New("Cannot perform action. Messages still loading")
|
||||
}
|
||||
|
||||
criteria := types.SearchCriteria{
|
||||
WithFlags: s.WithFlags,
|
||||
WithoutFlags: s.WithoutFlags,
|
||||
From: s.From,
|
||||
To: s.To,
|
||||
Cc: s.Cc,
|
||||
Headers: s.Headers,
|
||||
StartDate: s.StartDate,
|
||||
EndDate: s.EndDate,
|
||||
SearchBody: s.Body,
|
||||
SearchAll: s.All,
|
||||
Terms: s.Terms,
|
||||
}
|
||||
|
||||
if args[0] == "filter" {
|
||||
if len(args[1:]) == 0 {
|
||||
return Clear{}.Execute([]string{"clear"})
|
||||
}
|
||||
acct.SetStatus(state.FilterActivity("Filtering..."), state.Search(""))
|
||||
store.SetFilter(args[1:])
|
||||
store.SetFilter(&criteria)
|
||||
cb := func(msg types.WorkerMessage) {
|
||||
if _, ok := msg.(*types.Done); ok {
|
||||
acct.SetStatus(state.FilterResult(strings.Join(args, " ")))
|
||||
@@ -81,7 +187,7 @@ func (SearchFilter) Execute(args []string) error {
|
||||
// TODO: Remove when stores have multiple OnUpdate handlers
|
||||
ui.Invalidate()
|
||||
}
|
||||
store.Search(args, cb)
|
||||
store.Search(&criteria, cb)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ AERC-SEARCH(1)
|
||||
|
||||
aerc-search - search and filter patterns and options for *aerc*(1)
|
||||
|
||||
# MAILDIR & IMAP
|
||||
# SYNTAX
|
||||
|
||||
This syntax is common to all backends.
|
||||
|
||||
*:filter* [*-ruba*] [*-x* _<flag>_] [*-X* _<flag>_] [*-H* _Header: Value_] [*-f* _<from>_] [*-t* _<to>_] [*-c* _<cc>_] [*-d* _<start[,end]>_] [_<terms>_...]
|
||||
*:search* [*-ruba*] [*-x* _<flag>_] [*-X* _<flag>_] [*-H* _Header: Value_] [*-f* _<from>_] [*-t* _<to>_] [*-c* _<cc>_] [*-d* _<start[,end]>_] [_<terms>_...]
|
||||
@@ -75,6 +77,9 @@ aerc-search - search and filter patterns and options for *aerc*(1)
|
||||
|
||||
# NOTMUCH
|
||||
|
||||
For notmuch, it is possible to avoid using the above flags and only rely on
|
||||
notmuch search syntax.
|
||||
|
||||
*:filter* _query_...
|
||||
*:search* _query_...
|
||||
You can use the full notmuch query language as described in
|
||||
|
||||
1
go.mod
1
go.mod
@@ -6,7 +6,6 @@ require (
|
||||
git.sr.ht/~rjarry/go-opt v1.2.0
|
||||
git.sr.ht/~rockorager/go-jmap v0.3.0
|
||||
git.sr.ht/~rockorager/tcell-term v0.8.0
|
||||
git.sr.ht/~sircmpwn/getopt v1.0.0
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230417170513-8ee5748c52b5
|
||||
github.com/arran4/golang-ical v0.0.0-20230318005454-19abf92700cc
|
||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964
|
||||
|
||||
2
go.sum
2
go.sum
@@ -4,8 +4,6 @@ git.sr.ht/~rockorager/go-jmap v0.3.0 h1:h2WuPcNyXRYFg9+W2HGf/mzIqC6ISy9EaS/BGa7Z
|
||||
git.sr.ht/~rockorager/go-jmap v0.3.0/go.mod h1:aOTCtwpZSINpDDSOkLGpHU0Kbbm5lcSDMcobX3ZtOjY=
|
||||
git.sr.ht/~rockorager/tcell-term v0.8.0 h1:jAAzWgTAzMz8uMXbOLZd5WgV7qmb6zRE0Z7HUrDdVPs=
|
||||
git.sr.ht/~rockorager/tcell-term v0.8.0/go.mod h1:Snxh5CrziiA2CjyLOZ6tGAg5vMPlE+REMWT3rtKuyyQ=
|
||||
git.sr.ht/~sircmpwn/getopt v1.0.0 h1:/pRHjO6/OCbBF4puqD98n6xtPEgE//oq5U8NXjP7ROc=
|
||||
git.sr.ht/~sircmpwn/getopt v1.0.0/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw=
|
||||
github.com/ProtonMail/crypto v0.0.0-20200420072808-71bec3603bf3 h1:JW27/kGLQzeM1Fxg5YQhdkTEAU7HIAHMgSag35zVTnY=
|
||||
github.com/ProtonMail/crypto v0.0.0-20200420072808-71bec3603bf3/go.mod h1:Pxr7w4gA2ikI4sWyYwEffm+oew1WAJHzG1SiDpQMkrI=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20211112122917-428f8eabeeb3/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
|
||||
|
||||
@@ -42,7 +42,7 @@ type MessageStore struct {
|
||||
// Search/filter results
|
||||
results []uint32
|
||||
resultIndex int
|
||||
filter []string
|
||||
filter *types.SearchCriteria
|
||||
|
||||
sortCriteria []*types.SortCriterion
|
||||
sortDefault []*types.SortCriterion
|
||||
@@ -110,7 +110,6 @@ func NewMessageStore(worker *types.Worker,
|
||||
reverseThreadOrder: reverseThreadOrder,
|
||||
sortThreadSiblings: sortThreadSiblings,
|
||||
|
||||
filter: []string{"filter"},
|
||||
sortCriteria: defaultSortCriteria,
|
||||
sortDefault: defaultSortCriteria,
|
||||
|
||||
@@ -719,10 +718,10 @@ func (store *MessageStore) Prev() {
|
||||
store.NextPrev(-1)
|
||||
}
|
||||
|
||||
func (store *MessageStore) Search(args []string, cb func([]uint32)) {
|
||||
func (store *MessageStore) Search(terms *types.SearchCriteria, cb func([]uint32)) {
|
||||
store.worker.PostAction(&types.SearchDirectory{
|
||||
Context: store.ctx,
|
||||
Argv: args,
|
||||
Context: store.ctx,
|
||||
Criteria: terms,
|
||||
}, func(msg types.WorkerMessage) {
|
||||
if msg, ok := msg.(*types.SearchResults); ok {
|
||||
allowedUids := store.Uids()
|
||||
@@ -757,12 +756,12 @@ func (store *MessageStore) IsResult(uid uint32) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (store *MessageStore) SetFilter(args []string) {
|
||||
store.filter = append(store.filter, args...)
|
||||
func (store *MessageStore) SetFilter(terms *types.SearchCriteria) {
|
||||
store.filter = store.filter.Combine(terms)
|
||||
}
|
||||
|
||||
func (store *MessageStore) ApplyClear() {
|
||||
store.filter = []string{"filter"}
|
||||
store.filter = nil
|
||||
store.results = nil
|
||||
if store.onFilterChange != nil {
|
||||
store.onFilterChange(store)
|
||||
@@ -839,16 +838,16 @@ func (store *MessageStore) Sort(criteria []*types.SortCriterion, cb func(types.W
|
||||
|
||||
if store.threadedView && !store.buildThreads {
|
||||
store.worker.PostAction(&types.FetchDirectoryThreaded{
|
||||
Context: store.ctx,
|
||||
SortCriteria: criteria,
|
||||
FilterCriteria: store.filter,
|
||||
ThreadContext: store.threadContext,
|
||||
Context: store.ctx,
|
||||
SortCriteria: criteria,
|
||||
Filter: store.filter,
|
||||
ThreadContext: store.threadContext,
|
||||
}, handle_return)
|
||||
} else {
|
||||
store.worker.PostAction(&types.FetchDirectoryContents{
|
||||
Context: store.ctx,
|
||||
SortCriteria: criteria,
|
||||
FilterCriteria: store.filter,
|
||||
Context: store.ctx,
|
||||
SortCriteria: criteria,
|
||||
Filter: store.filter,
|
||||
}, handle_return)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,11 +115,7 @@ func (imapw *IMAPWorker) handleSearchDirectory(msg *types.SearchDirectory) {
|
||||
}
|
||||
|
||||
imapw.worker.Tracef("Executing search")
|
||||
criteria, err := parseSearch(msg.Argv)
|
||||
if err != nil {
|
||||
emitError(err)
|
||||
return
|
||||
}
|
||||
criteria := translateSearch(msg.Criteria)
|
||||
|
||||
if msg.Context.Err() != nil {
|
||||
imapw.worker.PostMessage(&types.Cancelled{
|
||||
|
||||
@@ -39,17 +39,11 @@ func (imapw *IMAPWorker) handleFetchDirectoryContents(
|
||||
}
|
||||
imapw.worker.Tracef("Fetching UID list")
|
||||
|
||||
searchCriteria, err := parseSearch(msg.FilterCriteria)
|
||||
if err != nil {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
searchCriteria := translateSearch(msg.Filter)
|
||||
sortCriteria := translateSortCriterions(msg.SortCriteria)
|
||||
hasSortCriteria := len(sortCriteria) > 0
|
||||
|
||||
var err error
|
||||
var uids []uint32
|
||||
|
||||
// If the server supports the SORT extension, do the sorting server side
|
||||
@@ -87,7 +81,7 @@ func (imapw *IMAPWorker) handleFetchDirectoryContents(
|
||||
return
|
||||
}
|
||||
imapw.worker.Tracef("Found %d UIDs", len(uids))
|
||||
if len(msg.FilterCriteria) == 1 {
|
||||
if msg.Filter == nil {
|
||||
// Only initialize if we are not filtering
|
||||
imapw.seqMap.Initialize(uids)
|
||||
}
|
||||
@@ -134,14 +128,7 @@ func (imapw *IMAPWorker) handleDirectoryThreaded(
|
||||
}
|
||||
imapw.worker.Tracef("Fetching threaded UID list")
|
||||
|
||||
searchCriteria, err := parseSearch(msg.FilterCriteria)
|
||||
if err != nil {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
searchCriteria := translateSearch(msg.Filter)
|
||||
threads, err := imapw.client.thread.UidThread(imapw.threadAlgorithm,
|
||||
searchCriteria)
|
||||
if err != nil {
|
||||
@@ -154,7 +141,7 @@ func (imapw *IMAPWorker) handleDirectoryThreaded(
|
||||
aercThreads, count := convertThreads(threads, nil)
|
||||
sort.Sort(types.ByUID(aercThreads))
|
||||
imapw.worker.Tracef("Found %d threaded messages", count)
|
||||
if len(msg.FilterCriteria) == 1 {
|
||||
if msg.Filter == nil {
|
||||
// Only initialize if we are not filtering
|
||||
var uids []uint32
|
||||
for i := len(aercThreads) - 1; i >= 0; i-- {
|
||||
|
||||
@@ -1,95 +1,50 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/parse"
|
||||
"git.sr.ht/~rjarry/aerc/log"
|
||||
"git.sr.ht/~sircmpwn/getopt"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rjarry/go-opt"
|
||||
)
|
||||
|
||||
func parseSearch(args []string) (*imap.SearchCriteria, error) {
|
||||
func translateSearch(c *types.SearchCriteria) *imap.SearchCriteria {
|
||||
criteria := imap.NewSearchCriteria()
|
||||
if len(args) == 0 {
|
||||
return criteria, nil
|
||||
if c == nil {
|
||||
return criteria
|
||||
}
|
||||
criteria.WithFlags = translateFlags(c.WithFlags)
|
||||
criteria.WithoutFlags = translateFlags(c.WithoutFlags)
|
||||
|
||||
opts, optind, err := getopt.Getopts(args, "rubax:X:t:H:f:c:d:")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if !c.StartDate.IsZero() {
|
||||
criteria.SentSince = c.StartDate
|
||||
}
|
||||
body := false
|
||||
text := false
|
||||
for _, opt := range opts {
|
||||
switch opt.Option {
|
||||
case 'r':
|
||||
criteria.WithFlags = append(criteria.WithFlags, imap.SeenFlag)
|
||||
case 'u':
|
||||
criteria.WithoutFlags = append(criteria.WithoutFlags, imap.SeenFlag)
|
||||
case 'x':
|
||||
if f, err := getParsedFlag(opt.Value); err == nil {
|
||||
criteria.WithFlags = append(criteria.WithFlags, f)
|
||||
}
|
||||
case 'X':
|
||||
if f, err := getParsedFlag(opt.Value); err == nil {
|
||||
criteria.WithoutFlags = append(criteria.WithoutFlags, f)
|
||||
}
|
||||
case 'H':
|
||||
if strings.Contains(opt.Value, ": ") {
|
||||
HeaderValue := strings.SplitN(opt.Value, ": ", 2)
|
||||
criteria.Header.Add(HeaderValue[0], HeaderValue[1])
|
||||
} else {
|
||||
log.Errorf("Header is not given properly, must be given in format `Header: Value`")
|
||||
continue
|
||||
}
|
||||
case 'f':
|
||||
criteria.Header.Add("From", opt.Value)
|
||||
case 't':
|
||||
criteria.Header.Add("To", opt.Value)
|
||||
case 'c':
|
||||
criteria.Header.Add("Cc", opt.Value)
|
||||
case 'b':
|
||||
body = true
|
||||
case 'a':
|
||||
text = true
|
||||
case 'd':
|
||||
start, end, err := parse.DateRange(opt.Value)
|
||||
if err != nil {
|
||||
log.Errorf("failed to parse start date: %v", err)
|
||||
continue
|
||||
}
|
||||
if !start.IsZero() {
|
||||
criteria.SentSince = start
|
||||
}
|
||||
if !end.IsZero() {
|
||||
criteria.SentBefore = end
|
||||
if !c.StartDate.IsZero() {
|
||||
criteria.SentBefore = c.EndDate
|
||||
}
|
||||
for k, v := range c.Headers {
|
||||
criteria.Header[k] = v
|
||||
}
|
||||
for _, f := range c.From {
|
||||
criteria.Header.Add("From", f)
|
||||
}
|
||||
for _, t := range c.To {
|
||||
criteria.Header.Add("To", t)
|
||||
}
|
||||
for _, c := range c.Cc {
|
||||
criteria.Header.Add("Cc", c)
|
||||
}
|
||||
terms := opt.LexArgs(c.Terms)
|
||||
if terms.Count() > 0 {
|
||||
switch {
|
||||
case c.SearchAll:
|
||||
criteria.Text = terms.Args()
|
||||
case c.SearchBody:
|
||||
criteria.Body = terms.Args()
|
||||
default:
|
||||
for _, term := range terms.Args() {
|
||||
criteria.Header.Add("Subject", term)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case text:
|
||||
criteria.Text = args[optind:]
|
||||
case body:
|
||||
criteria.Body = args[optind:]
|
||||
default:
|
||||
for _, arg := range args[optind:] {
|
||||
criteria.Header.Add("Subject", arg)
|
||||
}
|
||||
}
|
||||
return criteria, nil
|
||||
}
|
||||
|
||||
func getParsedFlag(name string) (string, error) {
|
||||
switch strings.ToLower(name) {
|
||||
case "seen":
|
||||
return imap.SeenFlag, nil
|
||||
case "flagged":
|
||||
return imap.FlaggedFlag, nil
|
||||
case "answered":
|
||||
return imap.AnsweredFlag, nil
|
||||
}
|
||||
return imap.FlaggedFlag, errors.New("Flag not suppored")
|
||||
return criteria
|
||||
}
|
||||
|
||||
33
worker/jmap/cache/cache.go
vendored
33
worker/jmap/cache/cache.go
vendored
@@ -4,10 +4,12 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"git.sr.ht/~rjarry/aerc/log"
|
||||
"github.com/syndtr/goleveldb/leveldb"
|
||||
"github.com/syndtr/goleveldb/leveldb/util"
|
||||
)
|
||||
|
||||
type JMAPCache struct {
|
||||
@@ -74,3 +76,34 @@ func (c *JMAPCache) delete(key string) error {
|
||||
}
|
||||
panic("jmap cache with no backend")
|
||||
}
|
||||
|
||||
func (c *JMAPCache) purge(prefix string) error {
|
||||
switch {
|
||||
case c.file != nil:
|
||||
txn, err := c.file.OpenTransaction()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
iter := txn.NewIterator(util.BytesPrefix([]byte(prefix)), nil)
|
||||
for iter.Next() {
|
||||
err = txn.Delete(iter.Key(), nil)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
iter.Release()
|
||||
if err != nil {
|
||||
txn.Discard()
|
||||
return err
|
||||
}
|
||||
return txn.Commit()
|
||||
case c.mem != nil:
|
||||
for key := range c.mem {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(c.mem, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
panic("jmap cache with no backend")
|
||||
}
|
||||
|
||||
17
worker/jmap/cache/folder_contents.go
vendored
17
worker/jmap/cache/folder_contents.go
vendored
@@ -3,26 +3,32 @@ package cache
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/log"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
)
|
||||
|
||||
type FolderContents struct {
|
||||
MailboxID jmap.ID
|
||||
QueryState string
|
||||
Filter *email.FilterCondition
|
||||
Sort []*email.SortComparator
|
||||
Filter *types.SearchCriteria
|
||||
Sort []*types.SortCriterion
|
||||
MessageIDs []jmap.ID
|
||||
}
|
||||
|
||||
func (c *JMAPCache) GetFolderContents(mailboxId jmap.ID) (*FolderContents, error) {
|
||||
buf, err := c.get(folderContentsKey(mailboxId))
|
||||
key := folderContentsKey(mailboxId)
|
||||
buf, err := c.get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := new(FolderContents)
|
||||
err = unmarshal(buf, m)
|
||||
if err != nil {
|
||||
log.Debugf("cache format has changed, purging foldercontents")
|
||||
if e := c.purge("foldercontents/"); e != nil {
|
||||
log.Errorf("foldercontents cache purge: %s", e)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
@@ -45,7 +51,7 @@ func folderContentsKey(mailboxId jmap.ID) string {
|
||||
}
|
||||
|
||||
func (f *FolderContents) NeedsRefresh(
|
||||
filter *email.FilterCondition, sort []*email.SortComparator,
|
||||
filter *types.SearchCriteria, sort []*types.SortCriterion,
|
||||
) bool {
|
||||
if f.QueryState == "" || f.Filter == nil || len(f.Sort) != len(sort) {
|
||||
return true
|
||||
@@ -56,6 +62,5 @@ func (f *FolderContents) NeedsRefresh(
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return !reflect.DeepEqual(filter, f.Filter)
|
||||
}
|
||||
|
||||
@@ -126,25 +126,16 @@ func (w *JMAPWorker) handleFetchDirectoryContents(msg *types.FetchDirectoryConte
|
||||
if err != nil {
|
||||
contents = &cache.FolderContents{
|
||||
MailboxID: w.selectedMbox,
|
||||
Filter: &email.FilterCondition{},
|
||||
}
|
||||
}
|
||||
|
||||
filter, err := parseSearch(msg.FilterCriteria)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter.InMailbox = w.selectedMbox
|
||||
|
||||
sort := translateSort(msg.SortCriteria)
|
||||
|
||||
if contents.NeedsRefresh(filter, sort) {
|
||||
if contents.NeedsRefresh(msg.Filter, msg.SortCriteria) {
|
||||
var req jmap.Request
|
||||
|
||||
req.Invoke(&email.Query{
|
||||
Account: w.accountId,
|
||||
Filter: filter,
|
||||
Sort: sort,
|
||||
Filter: w.translateSearch(w.selectedMbox, msg.Filter),
|
||||
Sort: translateSort(msg.SortCriteria),
|
||||
})
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
@@ -154,8 +145,8 @@ func (w *JMAPWorker) handleFetchDirectoryContents(msg *types.FetchDirectoryConte
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *email.QueryResponse:
|
||||
contents.Sort = sort
|
||||
contents.Filter = filter
|
||||
contents.Sort = msg.SortCriteria
|
||||
contents.Filter = msg.Filter
|
||||
contents.QueryState = r.QueryState
|
||||
contents.MessageIDs = r.IDs
|
||||
canCalculateChanges = r.CanCalculateChanges
|
||||
@@ -193,27 +184,9 @@ func (w *JMAPWorker) handleFetchDirectoryContents(msg *types.FetchDirectoryConte
|
||||
func (w *JMAPWorker) handleSearchDirectory(msg *types.SearchDirectory) error {
|
||||
var req jmap.Request
|
||||
|
||||
filter, err := parseSearch(msg.Argv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if w.selectedMbox == "" {
|
||||
// all mail virtual folder: display all but trash and spam
|
||||
var mboxes []jmap.ID
|
||||
if id, ok := w.roles[mailbox.RoleJunk]; ok {
|
||||
mboxes = append(mboxes, id)
|
||||
}
|
||||
if id, ok := w.roles[mailbox.RoleTrash]; ok {
|
||||
mboxes = append(mboxes, id)
|
||||
}
|
||||
filter.InMailboxOtherThan = mboxes
|
||||
} else {
|
||||
filter.InMailbox = w.selectedMbox
|
||||
}
|
||||
|
||||
req.Invoke(&email.Query{
|
||||
Account: w.accountId,
|
||||
Filter: filter,
|
||||
Filter: w.translateSearch(w.selectedMbox, msg.Criteria),
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
|
||||
@@ -127,8 +127,8 @@ func (w *JMAPWorker) refresh(newState jmap.TypeState) error {
|
||||
}
|
||||
callID = req.Invoke(&email.QueryChanges{
|
||||
Account: w.accountId,
|
||||
Filter: contents.Filter,
|
||||
Sort: contents.Sort,
|
||||
Filter: w.translateSearch(id, contents.Filter),
|
||||
Sort: translateSort(contents.Sort),
|
||||
SinceQueryState: contents.QueryState,
|
||||
})
|
||||
queryChangesCalls[callID] = id
|
||||
|
||||
@@ -1,63 +1,98 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/parse"
|
||||
"git.sr.ht/~rjarry/aerc/log"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
"git.sr.ht/~sircmpwn/getopt"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
|
||||
)
|
||||
|
||||
func parseSearch(args []string) (*email.FilterCondition, error) {
|
||||
f := new(email.FilterCondition)
|
||||
if len(args) == 0 {
|
||||
return f, nil
|
||||
func (w *JMAPWorker) translateSearch(
|
||||
mbox jmap.ID, criteria *types.SearchCriteria,
|
||||
) email.Filter {
|
||||
cond := new(email.FilterCondition)
|
||||
|
||||
if mbox == "" {
|
||||
// all mail virtual folder: display all but trash and spam
|
||||
var mboxes []jmap.ID
|
||||
if id, ok := w.roles[mailbox.RoleJunk]; ok {
|
||||
mboxes = append(mboxes, id)
|
||||
}
|
||||
if id, ok := w.roles[mailbox.RoleTrash]; ok {
|
||||
mboxes = append(mboxes, id)
|
||||
}
|
||||
cond.InMailboxOtherThan = mboxes
|
||||
} else {
|
||||
cond.InMailbox = mbox
|
||||
}
|
||||
if criteria == nil {
|
||||
return cond
|
||||
}
|
||||
|
||||
opts, optind, err := getopt.Getopts(args, "rubax:X:t:H:f:c:d:")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// dates
|
||||
if !criteria.StartDate.IsZero() {
|
||||
cond.After = &criteria.StartDate
|
||||
}
|
||||
body := false
|
||||
text := false
|
||||
for _, opt := range opts {
|
||||
switch opt.Option {
|
||||
case 'r':
|
||||
f.HasKeyword = "$seen"
|
||||
case 'u':
|
||||
f.NotKeyword = "$seen"
|
||||
case 'f':
|
||||
f.From = opt.Value
|
||||
case 't':
|
||||
f.To = opt.Value
|
||||
case 'c':
|
||||
f.Cc = opt.Value
|
||||
case 'b':
|
||||
body = true
|
||||
case 'a':
|
||||
text = true
|
||||
case 'd':
|
||||
start, end, err := parse.DateRange(opt.Value)
|
||||
if err != nil {
|
||||
log.Errorf("failed to parse start date: %v", err)
|
||||
continue
|
||||
}
|
||||
if !start.IsZero() {
|
||||
f.After = &start
|
||||
}
|
||||
if !end.IsZero() {
|
||||
f.Before = &end
|
||||
}
|
||||
if !criteria.EndDate.IsZero() {
|
||||
cond.Before = &criteria.EndDate
|
||||
}
|
||||
|
||||
// general search terms
|
||||
switch {
|
||||
case criteria.SearchAll:
|
||||
cond.Text = criteria.Terms
|
||||
case criteria.SearchBody:
|
||||
cond.Body = criteria.Terms
|
||||
default:
|
||||
cond.Subject = criteria.Terms
|
||||
}
|
||||
|
||||
filter := &email.FilterOperator{Operator: jmap.OperatorAND}
|
||||
filter.Conditions = append(filter.Conditions, cond)
|
||||
|
||||
// keywords/flags
|
||||
for kw := range flagsToKeywords(criteria.WithFlags) {
|
||||
filter.Conditions = append(filter.Conditions,
|
||||
&email.FilterCondition{HasKeyword: kw})
|
||||
}
|
||||
for kw := range flagsToKeywords(criteria.WithoutFlags) {
|
||||
filter.Conditions = append(filter.Conditions,
|
||||
&email.FilterCondition{NotKeyword: kw})
|
||||
}
|
||||
|
||||
// recipients
|
||||
addrs := &email.FilterOperator{
|
||||
Operator: jmap.OperatorOR,
|
||||
}
|
||||
for _, from := range criteria.From {
|
||||
addrs.Conditions = append(addrs.Conditions,
|
||||
&email.FilterCondition{From: from})
|
||||
}
|
||||
for _, to := range criteria.To {
|
||||
addrs.Conditions = append(addrs.Conditions,
|
||||
&email.FilterCondition{To: to})
|
||||
}
|
||||
for _, cc := range criteria.Cc {
|
||||
addrs.Conditions = append(addrs.Conditions,
|
||||
&email.FilterCondition{Cc: cc})
|
||||
}
|
||||
if len(addrs.Conditions) > 0 {
|
||||
filter.Conditions = append(filter.Conditions, addrs)
|
||||
}
|
||||
|
||||
// specific headers
|
||||
headers := &email.FilterOperator{
|
||||
Operator: jmap.OperatorAND,
|
||||
}
|
||||
for h, values := range criteria.Headers {
|
||||
for _, v := range values {
|
||||
headers.Conditions = append(headers.Conditions,
|
||||
&email.FilterCondition{Header: []string{h, v}})
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case text:
|
||||
f.Text = strings.Join(args[optind:], " ")
|
||||
case body:
|
||||
f.Body = strings.Join(args[optind:], " ")
|
||||
default:
|
||||
f.Subject = strings.Join(args[optind:], " ")
|
||||
if len(headers.Conditions) > 0 {
|
||||
filter.Conditions = append(filter.Conditions, headers)
|
||||
}
|
||||
return f, nil
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
@@ -2,111 +2,23 @@ package lib
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"git.sr.ht/~sircmpwn/getopt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/parse"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/rfc822"
|
||||
"git.sr.ht/~rjarry/aerc/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rjarry/go-opt"
|
||||
)
|
||||
|
||||
type searchCriteria struct {
|
||||
Header textproto.MIMEHeader
|
||||
Body []string
|
||||
Text []string
|
||||
|
||||
WithFlags models.Flags
|
||||
WithoutFlags models.Flags
|
||||
|
||||
startDate, endDate time.Time
|
||||
}
|
||||
|
||||
func GetSearchCriteria(args []string) (*searchCriteria, error) {
|
||||
criteria := &searchCriteria{Header: make(textproto.MIMEHeader)}
|
||||
|
||||
opts, optind, err := getopt.Getopts(args, "rux:X:bat:H:f:c:d:")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := false
|
||||
text := false
|
||||
for _, opt := range opts {
|
||||
switch opt.Option {
|
||||
case 'r':
|
||||
criteria.WithFlags |= models.SeenFlag
|
||||
case 'u':
|
||||
criteria.WithoutFlags |= models.SeenFlag
|
||||
case 'x':
|
||||
criteria.WithFlags |= getParsedFlag(opt.Value)
|
||||
case 'X':
|
||||
criteria.WithoutFlags |= getParsedFlag(opt.Value)
|
||||
case 'H':
|
||||
if strings.Contains(opt.Value, ": ") {
|
||||
HeaderValue := strings.SplitN(opt.Value, ": ", 2)
|
||||
criteria.Header.Add(HeaderValue[0], HeaderValue[1])
|
||||
} else {
|
||||
log.Errorf("Header is not given properly, must be given in format `Header: Value`")
|
||||
continue
|
||||
}
|
||||
case 'f':
|
||||
criteria.Header.Add("From", opt.Value)
|
||||
case 't':
|
||||
criteria.Header.Add("To", opt.Value)
|
||||
case 'c':
|
||||
criteria.Header.Add("Cc", opt.Value)
|
||||
case 'b':
|
||||
body = true
|
||||
case 'd':
|
||||
start, end, err := parse.DateRange(opt.Value)
|
||||
if err != nil {
|
||||
log.Errorf("failed to parse start date: %v", err)
|
||||
continue
|
||||
}
|
||||
if !start.IsZero() {
|
||||
criteria.startDate = start
|
||||
}
|
||||
if !end.IsZero() {
|
||||
criteria.endDate = end
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case text:
|
||||
criteria.Text = args[optind:]
|
||||
case body:
|
||||
criteria.Body = args[optind:]
|
||||
default:
|
||||
for _, arg := range args[optind:] {
|
||||
criteria.Header.Add("Subject", arg)
|
||||
}
|
||||
}
|
||||
return criteria, nil
|
||||
}
|
||||
|
||||
func getParsedFlag(name string) models.Flags {
|
||||
var f models.Flags
|
||||
switch strings.ToLower(name) {
|
||||
case "seen":
|
||||
f = models.SeenFlag
|
||||
case "answered":
|
||||
f = models.AnsweredFlag
|
||||
case "flagged":
|
||||
f = models.FlaggedFlag
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func Search(messages []rfc822.RawMessage, criteria *searchCriteria) ([]uint32, error) {
|
||||
requiredParts := getRequiredParts(criteria)
|
||||
func Search(messages []rfc822.RawMessage, criteria *types.SearchCriteria) ([]uint32, error) {
|
||||
requiredParts := GetRequiredParts(criteria)
|
||||
|
||||
matchedUids := []uint32{}
|
||||
for _, m := range messages {
|
||||
success, err := searchMessage(m, criteria, requiredParts)
|
||||
success, err := SearchMessage(m, criteria, requiredParts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if success {
|
||||
@@ -119,17 +31,19 @@ func Search(messages []rfc822.RawMessage, criteria *searchCriteria) ([]uint32, e
|
||||
|
||||
// searchMessage executes the search criteria for the given RawMessage,
|
||||
// returns true if search succeeded
|
||||
func searchMessage(message rfc822.RawMessage, criteria *searchCriteria,
|
||||
func SearchMessage(message rfc822.RawMessage, criteria *types.SearchCriteria,
|
||||
parts MsgParts,
|
||||
) (bool, error) {
|
||||
if criteria == nil {
|
||||
return true, nil
|
||||
}
|
||||
// setup parts of the message to use in the search
|
||||
// this is so that we try to minimise reading unnecessary parts
|
||||
var (
|
||||
flags models.Flags
|
||||
header *models.MessageInfo
|
||||
body string
|
||||
all string
|
||||
err error
|
||||
flags models.Flags
|
||||
info *models.MessageInfo
|
||||
text string
|
||||
err error
|
||||
)
|
||||
|
||||
if parts&FLAGS > 0 {
|
||||
@@ -138,14 +52,34 @@ func searchMessage(message rfc822.RawMessage, criteria *searchCriteria,
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if parts&HEADER > 0 || parts&DATE > 0 {
|
||||
header, err = rfc822.MessageInfo(message)
|
||||
if parts&HEADER > 0 || parts&DATE > 0 || (parts&(BODY|ALL)) == 0 {
|
||||
info, err = rfc822.MessageInfo(message)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if parts&BODY > 0 {
|
||||
// TODO: select body properly; this is just an 'all' clone
|
||||
switch {
|
||||
case parts&BODY > 0:
|
||||
path := lib.FindFirstNonMultipart(info.BodyStructure, nil)
|
||||
reader, err := message.NewReader()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer reader.Close()
|
||||
msg, err := rfc822.ReadMessage(reader)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
part, err := rfc822.FetchEntityPartReader(msg, path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
bytes, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
text = string(bytes)
|
||||
case parts&ALL > 0:
|
||||
reader, err := message.NewReader()
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -155,26 +89,16 @@ func searchMessage(message rfc822.RawMessage, criteria *searchCriteria,
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
body = string(bytes)
|
||||
}
|
||||
if parts&ALL > 0 {
|
||||
reader, err := message.NewReader()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer reader.Close()
|
||||
bytes, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
all = string(bytes)
|
||||
text = string(bytes)
|
||||
default:
|
||||
text = info.Envelope.Subject
|
||||
}
|
||||
|
||||
// now search through the criteria
|
||||
// implicit AND at the moment so fail fast
|
||||
if criteria.Header != nil {
|
||||
for k, v := range criteria.Header {
|
||||
headerValue := header.RFC822Headers.Get(k)
|
||||
if criteria.Headers != nil {
|
||||
for k, v := range criteria.Headers {
|
||||
headerValue := info.RFC822Headers.Get(k)
|
||||
for _, text := range v {
|
||||
if !containsSmartCase(headerValue, text) {
|
||||
return false, nil
|
||||
@@ -182,18 +106,11 @@ func searchMessage(message rfc822.RawMessage, criteria *searchCriteria,
|
||||
}
|
||||
}
|
||||
}
|
||||
if criteria.Body != nil {
|
||||
for _, searchTerm := range criteria.Body {
|
||||
if !containsSmartCase(body, searchTerm) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if criteria.Text != nil {
|
||||
for _, searchTerm := range criteria.Text {
|
||||
if !containsSmartCase(all, searchTerm) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
args := opt.LexArgs(criteria.Terms)
|
||||
for _, searchTerm := range args.Args() {
|
||||
if !containsSmartCase(text, searchTerm) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if criteria.WithFlags != 0 {
|
||||
@@ -207,16 +124,16 @@ func searchMessage(message rfc822.RawMessage, criteria *searchCriteria,
|
||||
}
|
||||
}
|
||||
if parts&DATE > 0 {
|
||||
if date, err := header.RFC822Headers.Date(); err != nil {
|
||||
if date, err := info.RFC822Headers.Date(); err != nil {
|
||||
log.Errorf("Failed to get date from header: %v", err)
|
||||
} else {
|
||||
if !criteria.startDate.IsZero() {
|
||||
if date.Before(criteria.startDate) {
|
||||
if !criteria.StartDate.IsZero() {
|
||||
if date.Before(criteria.StartDate) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if !criteria.endDate.IsZero() {
|
||||
if date.After(criteria.endDate) {
|
||||
if !criteria.EndDate.IsZero() {
|
||||
if date.After(criteria.EndDate) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
@@ -257,18 +174,21 @@ const (
|
||||
|
||||
// Returns a bitmask of the parts of the message required to be loaded for the
|
||||
// given criteria
|
||||
func getRequiredParts(criteria *searchCriteria) MsgParts {
|
||||
func GetRequiredParts(criteria *types.SearchCriteria) MsgParts {
|
||||
required := NONE
|
||||
if len(criteria.Header) > 0 {
|
||||
if criteria == nil {
|
||||
return required
|
||||
}
|
||||
if len(criteria.Headers) > 0 {
|
||||
required |= HEADER
|
||||
}
|
||||
if !criteria.startDate.IsZero() || !criteria.endDate.IsZero() {
|
||||
if !criteria.StartDate.IsZero() || !criteria.EndDate.IsZero() {
|
||||
required |= DATE
|
||||
}
|
||||
if criteria.Body != nil && len(criteria.Body) > 0 {
|
||||
if criteria.SearchBody {
|
||||
required |= BODY
|
||||
}
|
||||
if criteria.Text != nil && len(criteria.Text) > 0 {
|
||||
if criteria.SearchAll {
|
||||
required |= ALL
|
||||
}
|
||||
if criteria.WithFlags != 0 {
|
||||
|
||||
@@ -2,114 +2,17 @@ package maildir
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/textproto"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/emersion/go-maildir"
|
||||
|
||||
"git.sr.ht/~sircmpwn/getopt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/parse"
|
||||
"git.sr.ht/~rjarry/aerc/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/lib"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type searchCriteria struct {
|
||||
Header textproto.MIMEHeader
|
||||
Body []string
|
||||
Text []string
|
||||
func (w *Worker) search(ctx context.Context, criteria *types.SearchCriteria) ([]uint32, error) {
|
||||
requiredParts := lib.GetRequiredParts(criteria)
|
||||
|
||||
WithFlags []maildir.Flag
|
||||
WithoutFlags []maildir.Flag
|
||||
|
||||
startDate, endDate time.Time
|
||||
}
|
||||
|
||||
func parseSearch(args []string) (*searchCriteria, error) {
|
||||
criteria := &searchCriteria{Header: make(textproto.MIMEHeader)}
|
||||
|
||||
opts, optind, err := getopt.Getopts(args, "rux:X:bat:H:f:c:d:")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := false
|
||||
text := false
|
||||
for _, opt := range opts {
|
||||
switch opt.Option {
|
||||
case 'r':
|
||||
criteria.WithFlags = append(criteria.WithFlags, maildir.FlagSeen)
|
||||
case 'u':
|
||||
criteria.WithoutFlags = append(criteria.WithoutFlags, maildir.FlagSeen)
|
||||
case 'x':
|
||||
criteria.WithFlags = append(criteria.WithFlags, getParsedFlag(opt.Value))
|
||||
case 'X':
|
||||
criteria.WithoutFlags = append(criteria.WithoutFlags, getParsedFlag(opt.Value))
|
||||
case 'H':
|
||||
if strings.Contains(opt.Value, ": ") {
|
||||
HeaderValue := strings.SplitN(opt.Value, ": ", 2)
|
||||
criteria.Header.Add(HeaderValue[0], HeaderValue[1])
|
||||
} else {
|
||||
log.Errorf("Header is not given properly, must be given in format `Header: Value`")
|
||||
continue
|
||||
}
|
||||
case 'f':
|
||||
criteria.Header.Add("From", opt.Value)
|
||||
case 't':
|
||||
criteria.Header.Add("To", opt.Value)
|
||||
case 'c':
|
||||
criteria.Header.Add("Cc", opt.Value)
|
||||
case 'b':
|
||||
body = true
|
||||
case 'a':
|
||||
text = true
|
||||
case 'd':
|
||||
start, end, err := parse.DateRange(opt.Value)
|
||||
if err != nil {
|
||||
log.Errorf("failed to parse start date: %v", err)
|
||||
continue
|
||||
}
|
||||
if !start.IsZero() {
|
||||
criteria.startDate = start
|
||||
}
|
||||
if !end.IsZero() {
|
||||
criteria.endDate = end
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case text:
|
||||
criteria.Text = args[optind:]
|
||||
case body:
|
||||
criteria.Body = args[optind:]
|
||||
default:
|
||||
for _, arg := range args[optind:] {
|
||||
criteria.Header.Add("Subject", arg)
|
||||
}
|
||||
}
|
||||
return criteria, nil
|
||||
}
|
||||
|
||||
func getParsedFlag(name string) maildir.Flag {
|
||||
var f maildir.Flag
|
||||
switch strings.ToLower(name) {
|
||||
case "seen":
|
||||
f = maildir.FlagSeen
|
||||
case "answered":
|
||||
f = maildir.FlagReplied
|
||||
case "flagged":
|
||||
f = maildir.FlagFlagged
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (w *Worker) search(ctx context.Context, criteria *searchCriteria) ([]uint32, error) {
|
||||
requiredParts := getRequiredParts(criteria)
|
||||
w.worker.Debugf("Required parts bitmask for search: %b", requiredParts)
|
||||
|
||||
keys, err := w.c.UIDs(*w.selected)
|
||||
@@ -152,187 +55,12 @@ func (w *Worker) search(ctx context.Context, criteria *searchCriteria) ([]uint32
|
||||
}
|
||||
|
||||
// Execute the search criteria for the given key, returns true if search succeeded
|
||||
func (w *Worker) searchKey(key uint32, criteria *searchCriteria,
|
||||
parts MsgParts,
|
||||
func (w *Worker) searchKey(key uint32, criteria *types.SearchCriteria,
|
||||
parts lib.MsgParts,
|
||||
) (bool, error) {
|
||||
message, err := w.c.Message(*w.selected, key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// setup parts of the message to use in the search
|
||||
// this is so that we try to minimise reading unnecessary parts
|
||||
var (
|
||||
flags []maildir.Flag
|
||||
header *models.MessageInfo
|
||||
body string
|
||||
all string
|
||||
)
|
||||
|
||||
if parts&FLAGS > 0 {
|
||||
flags, err = message.Flags()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if parts&HEADER > 0 || parts&DATE > 0 {
|
||||
header, err = message.MessageInfo()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if parts&BODY > 0 {
|
||||
// TODO: select which part to search, maybe look for text/plain
|
||||
mi, err := message.MessageInfo()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
path := lib.FindFirstNonMultipart(mi.BodyStructure, nil)
|
||||
reader, err := message.NewBodyPartReader(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
bytes, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
body = string(bytes)
|
||||
}
|
||||
if parts&ALL > 0 {
|
||||
reader, err := message.NewReader()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer reader.Close()
|
||||
bytes, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
all = string(bytes)
|
||||
}
|
||||
|
||||
// now search through the criteria
|
||||
// implicit AND at the moment so fail fast
|
||||
if criteria.Header != nil {
|
||||
for k, v := range criteria.Header {
|
||||
headerValue := header.RFC822Headers.Get(k)
|
||||
for _, text := range v {
|
||||
if !containsSmartCase(headerValue, text) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if criteria.Body != nil {
|
||||
for _, searchTerm := range criteria.Body {
|
||||
if !containsSmartCase(body, searchTerm) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if criteria.Text != nil {
|
||||
for _, searchTerm := range criteria.Text {
|
||||
if !containsSmartCase(all, searchTerm) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if criteria.WithFlags != nil {
|
||||
for _, searchFlag := range criteria.WithFlags {
|
||||
if !containsFlag(flags, searchFlag) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if criteria.WithoutFlags != nil {
|
||||
for _, searchFlag := range criteria.WithoutFlags {
|
||||
if containsFlag(flags, searchFlag) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if parts&DATE > 0 {
|
||||
if date, err := header.RFC822Headers.Date(); err != nil {
|
||||
w.worker.Errorf("Failed to get date from header: %v", err)
|
||||
} else {
|
||||
if !criteria.startDate.IsZero() {
|
||||
if date.Before(criteria.startDate) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if !criteria.endDate.IsZero() {
|
||||
if date.After(criteria.endDate) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Returns true if searchFlag appears in flags
|
||||
func containsFlag(flags []maildir.Flag, searchFlag maildir.Flag) bool {
|
||||
match := false
|
||||
for _, flag := range flags {
|
||||
if searchFlag == flag {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
// Smarter version of strings.Contains for searching.
|
||||
// Is case-insensitive unless substr contains an upper case character
|
||||
func containsSmartCase(s string, substr string) bool {
|
||||
if hasUpper(substr) {
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
||||
}
|
||||
|
||||
func hasUpper(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsUpper(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The parts of a message, kind of
|
||||
type MsgParts int
|
||||
|
||||
const NONE MsgParts = 0
|
||||
const (
|
||||
FLAGS MsgParts = 1 << iota
|
||||
HEADER
|
||||
DATE
|
||||
BODY
|
||||
ALL
|
||||
)
|
||||
|
||||
// Returns a bitmask of the parts of the message required to be loaded for the
|
||||
// given criteria
|
||||
func getRequiredParts(criteria *searchCriteria) MsgParts {
|
||||
required := NONE
|
||||
if len(criteria.Header) > 0 {
|
||||
required |= HEADER
|
||||
}
|
||||
if !criteria.startDate.IsZero() || !criteria.endDate.IsZero() {
|
||||
required |= DATE
|
||||
}
|
||||
if criteria.Body != nil && len(criteria.Body) > 0 {
|
||||
required |= BODY
|
||||
}
|
||||
if criteria.Text != nil && len(criteria.Text) > 0 {
|
||||
required |= ALL
|
||||
}
|
||||
if criteria.WithFlags != nil && len(criteria.WithFlags) > 0 {
|
||||
required |= FLAGS
|
||||
}
|
||||
if criteria.WithoutFlags != nil && len(criteria.WithoutFlags) > 0 {
|
||||
required |= FLAGS
|
||||
}
|
||||
|
||||
return required
|
||||
return lib.SearchMessage(message, criteria, parts)
|
||||
}
|
||||
|
||||
@@ -461,13 +461,8 @@ func (w *Worker) handleFetchDirectoryContents(
|
||||
uids []uint32
|
||||
err error
|
||||
)
|
||||
// FilterCriteria always contains "filter" as first item
|
||||
if len(msg.FilterCriteria) > 1 {
|
||||
filter, err := parseSearch(msg.FilterCriteria)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uids, err = w.search(msg.Context, filter)
|
||||
if msg.Filter != nil {
|
||||
uids, err = w.search(msg.Context, msg.Filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -546,12 +541,8 @@ func (w *Worker) handleFetchDirectoryThreaded(
|
||||
uids []uint32
|
||||
err error
|
||||
)
|
||||
if len(msg.FilterCriteria) > 1 {
|
||||
filter, err := parseSearch(msg.FilterCriteria)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uids, err = w.search(msg.Context, filter)
|
||||
if msg.Filter != nil {
|
||||
uids, err = w.search(msg.Context, msg.Filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -871,13 +862,8 @@ func (w *Worker) handleAppendMessage(msg *types.AppendMessage) error {
|
||||
}
|
||||
|
||||
func (w *Worker) handleSearchDirectory(msg *types.SearchDirectory) error {
|
||||
w.worker.Debugf("Searching directory %v with args: %v", *w.selected, msg.Argv)
|
||||
criteria, err := parseSearch(msg.Argv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.worker.Tracef("Searching with parsed criteria: %#v", criteria)
|
||||
uids, err := w.search(msg.Context, criteria)
|
||||
w.worker.Tracef("Searching with criteria: %#v", msg.Criteria)
|
||||
uids, err := w.search(msg.Context, msg.Criteria)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func (w *mboxWorker) handleMessage(msg types.WorkerMessage) error {
|
||||
w.worker.Debugf("%s opened", msg.Directory)
|
||||
|
||||
case *types.FetchDirectoryContents:
|
||||
uids, err := filterUids(w.folder, w.folder.Uids(), msg.FilterCriteria)
|
||||
uids, err := filterUids(w.folder, w.folder.Uids(), msg.Filter)
|
||||
if err != nil {
|
||||
reterr = err
|
||||
break
|
||||
@@ -339,7 +339,7 @@ func (w *mboxWorker) handleMessage(msg types.WorkerMessage) error {
|
||||
&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
|
||||
case *types.SearchDirectory:
|
||||
uids, err := filterUids(w.folder, w.folder.Uids(), msg.Argv)
|
||||
uids, err := filterUids(w.folder, w.folder.Uids(), msg.Criteria)
|
||||
if err != nil {
|
||||
reterr = err
|
||||
break
|
||||
@@ -405,11 +405,7 @@ func (w *mboxWorker) PathSeparator() string {
|
||||
return "/"
|
||||
}
|
||||
|
||||
func filterUids(folder *container, uids []uint32, args []string) ([]uint32, error) {
|
||||
criteria, err := lib.GetSearchCriteria(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
func filterUids(folder *container, uids []uint32, criteria *types.SearchCriteria) ([]uint32, error) {
|
||||
log.Debugf("Search with parsed criteria: %#v", criteria)
|
||||
m := make([]rfc822.RawMessage, 0, len(uids))
|
||||
for _, uid := range uids {
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
package notmuch
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/log"
|
||||
"git.sr.ht/~sircmpwn/getopt"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rjarry/go-opt"
|
||||
)
|
||||
|
||||
type queryBuilder struct {
|
||||
s string
|
||||
}
|
||||
|
||||
func (q *queryBuilder) add(s string) {
|
||||
func (q *queryBuilder) and(s string) {
|
||||
if len(s) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -25,62 +25,93 @@ func (q *queryBuilder) add(s string) {
|
||||
q.s += "(" + s + ")"
|
||||
}
|
||||
|
||||
func translate(args []string) (string, error) {
|
||||
if len(args) == 0 {
|
||||
return "", nil
|
||||
func (q *queryBuilder) or(s string) {
|
||||
if len(s) == 0 {
|
||||
return
|
||||
}
|
||||
var qb queryBuilder
|
||||
opts, optind, err := getopt.Getopts(args, "rux:X:bat:H:f:c:d:")
|
||||
if err != nil {
|
||||
// if error occurs here, don't fail
|
||||
log.Errorf("getopts failed: %v", err)
|
||||
return strings.Join(args[1:], ""), nil
|
||||
if len(q.s) != 0 {
|
||||
q.s += " or "
|
||||
}
|
||||
body := false
|
||||
for _, opt := range opts {
|
||||
switch opt.Option {
|
||||
case 'r':
|
||||
qb.add("not tag:unread")
|
||||
case 'u':
|
||||
qb.add("tag:unread")
|
||||
case 'x':
|
||||
qb.add(getParsedFlag(opt.Value))
|
||||
case 'X':
|
||||
qb.add("not " + getParsedFlag(opt.Value))
|
||||
case 'H':
|
||||
// TODO
|
||||
case 'f':
|
||||
qb.add("from:" + opt.Value)
|
||||
case 't':
|
||||
qb.add("to:" + opt.Value)
|
||||
case 'c':
|
||||
qb.add("cc:" + opt.Value)
|
||||
case 'a':
|
||||
// TODO
|
||||
case 'b':
|
||||
body = true
|
||||
case 'd':
|
||||
qb.add("date:" + strconv.Quote(opt.Value))
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case body:
|
||||
qb.add("body:" + strconv.Quote(strings.Join(args[optind:], " ")))
|
||||
default:
|
||||
qb.add(strings.Join(args[optind:], " "))
|
||||
}
|
||||
return qb.s, nil
|
||||
q.s += "(" + s + ")"
|
||||
}
|
||||
|
||||
func getParsedFlag(name string) string {
|
||||
switch strings.ToLower(name) {
|
||||
case "answered":
|
||||
return "tag:replied"
|
||||
case "seen":
|
||||
return "(not tag:unread)"
|
||||
case "flagged":
|
||||
return "tag:flagged"
|
||||
default:
|
||||
return name
|
||||
func translate(crit *types.SearchCriteria) string {
|
||||
if crit == nil {
|
||||
return ""
|
||||
}
|
||||
var base queryBuilder
|
||||
|
||||
// recipients
|
||||
var from queryBuilder
|
||||
for _, f := range crit.From {
|
||||
from.or("from:" + opt.QuoteArg(f))
|
||||
}
|
||||
if from.s != "" {
|
||||
base.and(from.s)
|
||||
}
|
||||
|
||||
var to queryBuilder
|
||||
for _, t := range crit.To {
|
||||
to.or("to:" + opt.QuoteArg(t))
|
||||
}
|
||||
if to.s != "" {
|
||||
base.and(to.s)
|
||||
}
|
||||
|
||||
var cc queryBuilder
|
||||
for _, c := range crit.Cc {
|
||||
cc.or("cc:" + opt.QuoteArg(c))
|
||||
}
|
||||
if cc.s != "" {
|
||||
base.and(cc.s)
|
||||
}
|
||||
|
||||
// flags
|
||||
for _, f := range []models.Flags{models.SeenFlag, models.AnsweredFlag, models.FlaggedFlag} {
|
||||
if crit.WithFlags.Has(f) {
|
||||
base.and(getParsedFlag(f, false))
|
||||
}
|
||||
if crit.WithoutFlags.Has(f) {
|
||||
base.and(getParsedFlag(f, true))
|
||||
}
|
||||
}
|
||||
|
||||
// dates
|
||||
switch {
|
||||
case !crit.StartDate.IsZero() && !crit.EndDate.IsZero():
|
||||
base.and(fmt.Sprintf("date:@%d..@%d",
|
||||
crit.StartDate.Unix(), crit.EndDate.Unix()))
|
||||
case !crit.StartDate.IsZero():
|
||||
base.and(fmt.Sprintf("date:@%d..", crit.StartDate.Unix()))
|
||||
case !crit.EndDate.IsZero():
|
||||
base.and(fmt.Sprintf("date:..@%d", crit.EndDate.Unix()))
|
||||
}
|
||||
|
||||
// other terms
|
||||
if crit.Terms != "" {
|
||||
if crit.SearchBody {
|
||||
base.and("body:" + opt.QuoteArg(crit.Terms))
|
||||
} else {
|
||||
base.and(crit.Terms)
|
||||
}
|
||||
}
|
||||
|
||||
return base.s
|
||||
}
|
||||
|
||||
func getParsedFlag(flag models.Flags, inverse bool) string {
|
||||
name := ""
|
||||
switch flag {
|
||||
case models.AnsweredFlag:
|
||||
name = "tag:replied"
|
||||
case models.SeenFlag:
|
||||
name = "tag:unread"
|
||||
inverse = !inverse
|
||||
case models.FlaggedFlag:
|
||||
name = "tag:flagged"
|
||||
}
|
||||
if inverse {
|
||||
name = "not " + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
@@ -531,13 +531,7 @@ func (w *worker) handleFlagMessages(msg *types.FlagMessages) error {
|
||||
}
|
||||
|
||||
func (w *worker) handleSearchDirectory(msg *types.SearchDirectory) error {
|
||||
// the first item is the command (:search)
|
||||
log.Debugf("search args: %v", msg.Argv)
|
||||
s, err := translate(msg.Argv)
|
||||
if err != nil {
|
||||
log.Debugf("ERROR: %v", err)
|
||||
return err
|
||||
}
|
||||
s := translate(msg.Criteria)
|
||||
// we only want to search in the current query, so merge the two together
|
||||
search := w.query
|
||||
if s != "" {
|
||||
@@ -605,11 +599,7 @@ func (w *worker) emitDirectoryContents(parent types.WorkerMessage) error {
|
||||
query := w.query
|
||||
ctx := context.Background()
|
||||
if msg, ok := parent.(*types.FetchDirectoryContents); ok {
|
||||
log.Debugf("filter input: '%v'", msg.FilterCriteria)
|
||||
s, err := translate(msg.FilterCriteria)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s := translate(msg.Filter)
|
||||
if s != "" {
|
||||
query = fmt.Sprintf("(%v) and (%v)", query, s)
|
||||
log.Debugf("filter query: '%s'", query)
|
||||
@@ -637,11 +627,7 @@ func (w *worker) emitDirectoryThreaded(parent types.WorkerMessage) error {
|
||||
ctx := context.Background()
|
||||
threadContext := false
|
||||
if msg, ok := parent.(*types.FetchDirectoryThreaded); ok {
|
||||
log.Debugf("filter input: '%v'", msg.FilterCriteria)
|
||||
s, err := translate(msg.FilterCriteria)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s := translate(msg.Filter)
|
||||
if s != "" {
|
||||
query = fmt.Sprintf("(%v) and (%v)", query, s)
|
||||
log.Debugf("filter query: '%s'", query)
|
||||
|
||||
@@ -105,23 +105,23 @@ type OpenDirectory struct {
|
||||
|
||||
type FetchDirectoryContents struct {
|
||||
Message
|
||||
Context context.Context
|
||||
SortCriteria []*SortCriterion
|
||||
FilterCriteria []string
|
||||
Context context.Context
|
||||
SortCriteria []*SortCriterion
|
||||
Filter *SearchCriteria
|
||||
}
|
||||
|
||||
type FetchDirectoryThreaded struct {
|
||||
Message
|
||||
Context context.Context
|
||||
SortCriteria []*SortCriterion
|
||||
FilterCriteria []string
|
||||
ThreadContext bool
|
||||
Context context.Context
|
||||
SortCriteria []*SortCriterion
|
||||
Filter *SearchCriteria
|
||||
ThreadContext bool
|
||||
}
|
||||
|
||||
type SearchDirectory struct {
|
||||
Message
|
||||
Context context.Context
|
||||
Argv []string
|
||||
Context context.Context
|
||||
Criteria *SearchCriteria
|
||||
}
|
||||
|
||||
type DirectoryThreaded struct {
|
||||
|
||||
66
worker/types/search.go
Normal file
66
worker/types/search.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
type SearchCriteria struct {
|
||||
WithFlags models.Flags
|
||||
WithoutFlags models.Flags
|
||||
From []string
|
||||
To []string
|
||||
Cc []string
|
||||
Headers textproto.MIMEHeader
|
||||
StartDate time.Time
|
||||
EndDate time.Time
|
||||
SearchBody bool
|
||||
SearchAll bool
|
||||
Terms string
|
||||
}
|
||||
|
||||
func (c *SearchCriteria) Combine(other *SearchCriteria) *SearchCriteria {
|
||||
if c == nil {
|
||||
return other
|
||||
}
|
||||
headers := make(textproto.MIMEHeader)
|
||||
for k, v := range c.Headers {
|
||||
headers[k] = v
|
||||
}
|
||||
for k, v := range other.Headers {
|
||||
headers[k] = v
|
||||
}
|
||||
start := c.StartDate
|
||||
if !other.StartDate.IsZero() {
|
||||
start = other.StartDate
|
||||
}
|
||||
end := c.EndDate
|
||||
if !other.EndDate.IsZero() {
|
||||
end = other.EndDate
|
||||
}
|
||||
from := make([]string, len(c.From)+len(other.From))
|
||||
copy(from[:len(c.From)], c.From)
|
||||
copy(from[len(c.From):], other.From)
|
||||
to := make([]string, len(c.To)+len(other.To))
|
||||
copy(to[:len(c.To)], c.To)
|
||||
copy(to[len(c.To):], other.To)
|
||||
cc := make([]string, len(c.Cc)+len(other.Cc))
|
||||
copy(cc[:len(c.Cc)], c.Cc)
|
||||
copy(cc[len(c.Cc):], other.Cc)
|
||||
return &SearchCriteria{
|
||||
WithFlags: c.WithFlags | other.WithFlags,
|
||||
WithoutFlags: c.WithoutFlags | other.WithoutFlags,
|
||||
From: from,
|
||||
To: to,
|
||||
Cc: cc,
|
||||
Headers: headers,
|
||||
StartDate: start,
|
||||
EndDate: end,
|
||||
SearchBody: c.SearchBody || other.SearchBody,
|
||||
SearchAll: c.SearchAll || other.SearchAll,
|
||||
Terms: strings.Join([]string{c.Terms, other.Terms}, " "),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user