aboutsummaryrefslogtreecommitdiffstats
path: root/worker
diff options
context:
space:
mode:
Diffstat (limited to 'worker')
-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
16 files changed, 414 insertions, 717 deletions
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}, " "),
+ }
+}