aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorRobin Jarry <robin@jarry.cc>2023-10-17 15:31:09 +0200
committerRobin Jarry <robin@jarry.cc>2023-10-28 19:24:59 +0200
commit8464b373851142b0becaaa10db34df3559b2b62e (patch)
treedd785d6c717f1e620c63252348d8add991587a57
parent57088312fdd8e602a084bd5736a0e22a34be9ec0 (diff)
downloadaerc-8464b373851142b0becaaa10db34df3559b2b62e.tar.gz
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>
-rw-r--r--commands/account/search.go114
-rw-r--r--doc/aerc-search.1.scd7
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--lib/msgstore.go29
-rw-r--r--worker/imap/list.go6
-rw-r--r--worker/imap/open.go23
-rw-r--r--worker/imap/search.go115
-rw-r--r--worker/jmap/cache/cache.go33
-rw-r--r--worker/jmap/cache/folder_contents.go17
-rw-r--r--worker/jmap/directories.go39
-rw-r--r--worker/jmap/push.go4
-rw-r--r--worker/jmap/search.go137
-rw-r--r--worker/lib/search.go188
-rw-r--r--worker/maildir/search.go286
-rw-r--r--worker/maildir/worker.go26
-rw-r--r--worker/mbox/worker.go10
-rw-r--r--worker/notmuch/search.go143
-rw-r--r--worker/notmuch/worker.go20
-rw-r--r--worker/types/messages.go18
-rw-r--r--worker/types/search.go66
21 files changed, 544 insertions, 740 deletions
diff --git a/commands/account/search.go b/commands/account/search.go
index 7b98d98b..bb5617c0 100644
--- a/commands/account/search.go
+++ b/commands/account/search.go
@@ -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
}
diff --git a/doc/aerc-search.1.scd b/doc/aerc-search.1.scd
index d4a57017..284e1f7f 100644
--- a/doc/aerc-search.1.scd
+++ b/doc/aerc-search.1.scd
@@ -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
diff --git a/go.mod b/go.mod
index d5121870..6705dffc 100644
--- a/go.mod
+++ b/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
diff --git a/go.sum b/go.sum
index 4c15283e..a2253789 100644
--- a/go.sum
+++ b/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=
diff --git a/lib/msgstore.go b/lib/msgstore.go
index ab48f331..c68c2ed8 100644
--- a/lib/msgstore.go
+++ b/lib/msgstore.go
@@ -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)
}
}
diff --git a/worker/imap/list.go b/worker/imap/list.go
index fc1d7e94..5a97d5e2 100644
--- a/worker/imap/list.go
+++ b/worker/imap/list.go
@@ -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{
diff --git a/worker/imap/open.go b/worker/imap/open.go
index 693d93a9..355709a7 100644
--- a/worker/imap/open.go
+++ b/worker/imap/open.go
@@ -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-- {
diff --git a/worker/imap/search.go b/worker/imap/search.go
index e9238190..0305a80f 100644
--- a/worker/imap/search.go
+++ b/worker/imap/search.go
@@ -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
}
- switch {
- case text:
- criteria.Text = args[optind:]
- case body:
- criteria.Body = args[optind:]
- default:
- for _, arg := range args[optind:] {
- criteria.Header.Add("Subject", arg)
- }
+ for k, v := range c.Headers {
+ criteria.Header[k] = v
}
- 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
+ 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)
+ }
+ }
}
- return imap.FlaggedFlag, errors.New("Flag not suppored")
+ return criteria
}
diff --git a/worker/jmap/cache/cache.go b/worker/jmap/cache/cache.go
index 249ed0e9..6d815177 100644
--- a/worker/jmap/cache/cache.go
+++ b/worker/jmap/cache/cache.go
@@ -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")
+}
diff --git a/worker/jmap/cache/folder_contents.go b/worker/jmap/cache/folder_contents.go
index 6c6a7d80..46a36607 100644
--- a/worker/jmap/cache/folder_contents.go
+++ b/worker/jmap/cache/folder_contents.go
@@ -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)
}
diff --git a/worker/jmap/directories.go b/worker/jmap/directories.go
index 0fa3d898..bc47e691 100644
--- a/worker/jmap/directories.go
+++ b/worker/jmap/directories.go
@@ -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)
diff --git a/worker/jmap/push.go b/worker/jmap/push.go
index 320fee4f..34a90ca4 100644
--- a/worker/jmap/push.go
+++ b/worker/jmap/push.go
@@ -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
diff --git a/worker/jmap/search.go b/worker/jmap/search.go
index 17b5ca11..0101ce9b 100644
--- a/worker/jmap/search.go
+++ b/worker/jmap/search.go
@@ -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
- }
-
- opts, optind, err := getopt.Getopts(args, "rubax:X:t:H:f:c:d:")
- if err != nil {
- return nil, err
- }
- 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
- }
+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
+ }
+
+ // dates
+ if !criteria.StartDate.IsZero() {
+ cond.After = &criteria.StartDate
}
+ if !criteria.EndDate.IsZero() {
+ cond.Before = &criteria.EndDate
+ }
+
+ // general search terms
switch {
- case text:
- f.Text = strings.Join(args[optind:], " ")
- case body:
- f.Body = strings.Join(args[optind:], " ")
+ case criteria.SearchAll:
+ cond.Text = criteria.Terms
+ case criteria.SearchBody:
+ cond.Body = criteria.Terms
default:
- f.Subject = strings.Join(args[optind:], " ")
+ 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})
}
- return f, nil
+
+ // 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}})
+ }
+ }
+ if len(headers.Conditions) > 0 {
+ filter.Conditions = append(filter.Conditions, headers)
+ }
+
+ return filter
}
diff --git a/worker/lib/search.go b/worker/lib/search.go
index cd372aae..a3604430 100644
--- a/worker/lib/search.go
+++ b/worker/lib/search.go
@@ -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,26 +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()
- bytes, err := io.ReadAll(reader)
+ msg, err := rfc822.ReadMessage(reader)
if err != nil {
return false, err
}
- body = string(bytes)
- }
- if parts&ALL > 0 {
+ 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
@@ -167,14 +89,16 @@ func searchMessage(message rfc822.RawMessage, criteria *searchCriteria,
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 {
diff --git a/worker/maildir/search.go b/worker/maildir/search.go
index c1ea4de2..cf954753 100644
--- a/worker/maildir/search.go
+++ b/worker/maildir/search.go
@@ -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
-
- 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 *types.SearchCriteria) ([]uint32, error) {
+ requiredParts := lib.GetRequiredParts(criteria)
-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)
}
diff --git a/worker/maildir/worker.go b/worker/maildir/worker.go
index 0be6137f..f843d002 100644
--- a/worker/maildir/worker.go
+++ b/worker/maildir/worker.go
@@ -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
}
diff --git a/worker/mbox/worker.go b/worker/mbox/worker.go
index 160dfa55..5de3e30d 100644
--- a/worker/mbox/worker.go
+++ b/worker/mbox/worker.go
@@ -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 {
diff --git a/worker/notmuch/search.go b/worker/notmuch/search.go
index e1428b45..b3592d41 100644
--- a/worker/notmuch/search.go
+++ b/worker/notmuch/search.go
@@ -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
- }
- 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
- }
- 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))
+func (q *queryBuilder) or(s string) {
+ if len(s) == 0 {
+ return
+ }
+ if len(q.s) != 0 {
+ q.s += " or "
+ }
+ q.s += "(" + s + ")"
+}
+
+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 body:
- qb.add("body:" + strconv.Quote(strings.Join(args[optind:], " ")))
- default:
- qb.add(strings.Join(args[optind:], " "))
+ 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 qb.s, nil
+
+ return base.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 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
}
diff --git a/worker/notmuch/worker.go b/worker/notmuch/worker.go
index 2f601528..486312a1 100644
--- a/worker/notmuch/worker.go
+++ b/worker/notmuch/worker.go
@@ -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)
diff --git a/worker/types/messages.go b/worker/types/messages.go
index 7cab9a7a..35310b98 100644
--- a/worker/types/messages.go
+++ b/worker/types/messages.go
@@ -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 {
diff --git a/worker/types/search.go b/worker/types/search.go
new file mode 100644
index 00000000..b1960cad
--- /dev/null
+++ b/worker/types/search.go
@@ -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}, " "),
+ }
+}