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:
Robin Jarry
2023-10-17 15:31:09 +02:00
parent 57088312fd
commit 8464b37385
21 changed files with 553 additions and 749 deletions

View File

@@ -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
}

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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)
}
}

View File

@@ -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{

View File

@@ -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-- {

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
View 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}, " "),
}
}