diff options
-rw-r--r-- | commands/msg/archive.go | 26 | ||||
-rw-r--r-- | commands/msg/copy.go | 29 | ||||
-rw-r--r-- | commands/msg/delete.go | 23 | ||||
-rw-r--r-- | commands/msg/move.go | 32 | ||||
-rw-r--r-- | commands/msg/recall.go | 1 | ||||
-rw-r--r-- | commands/msg/reply.go | 2 | ||||
-rw-r--r-- | doc/aerc-notmuch.5.scd | 24 | ||||
-rw-r--r-- | doc/aerc.1.scd | 23 | ||||
-rw-r--r-- | lib/msgstore.go | 18 | ||||
-rw-r--r-- | worker/notmuch/message.go | 168 | ||||
-rw-r--r-- | worker/notmuch/message_test.go | 264 | ||||
-rw-r--r-- | worker/notmuch/worker.go | 62 | ||||
-rw-r--r-- | worker/types/messages.go | 13 | ||||
-rw-r--r-- | worker/types/mfs.go | 33 |
14 files changed, 586 insertions, 132 deletions
diff --git a/commands/msg/archive.go b/commands/msg/archive.go index 0714a805..8c5f12b9 100644 --- a/commands/msg/archive.go +++ b/commands/msg/archive.go @@ -22,7 +22,19 @@ const ( var ARCHIVE_TYPES = []string{ARCHIVE_FLAT, ARCHIVE_YEAR, ARCHIVE_MONTH} type Archive struct { - Type string `opt:"type" action:"ParseArchiveType" metavar:"flat|year|month" complete:"CompleteType"` + MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS"` + Type string `opt:"type" action:"ParseArchiveType" metavar:"flat|year|month" complete:"CompleteType"` +} + +func (a *Archive) ParseMFS(arg string) error { + if arg != "" { + mfs, ok := types.StrToStrategy[arg] + if !ok { + return fmt.Errorf("invalid multi-file strategy %s", arg) + } + a.MultiFileStrategy = &mfs + } + return nil } func (a *Archive) ParseArchiveType(arg string) error { @@ -47,6 +59,10 @@ func (Archive) Aliases() []string { return []string{"archive"} } +func (Archive) CompleteMFS(arg string) []string { + return commands.FilterList(types.StrategyStrs(), arg, nil) +} + func (*Archive) CompleteType(arg string) []string { return commands.FilterList(ARCHIVE_TYPES, arg, nil) } @@ -57,11 +73,13 @@ func (a Archive) Execute(args []string) error { if err != nil { return err } - err = archive(msgs, a.Type) + err = archive(msgs, a.MultiFileStrategy, a.Type) return err } -func archive(msgs []*models.MessageInfo, archiveType string) error { +func archive(msgs []*models.MessageInfo, mfs *types.MultiFileStrategy, + archiveType string, +) error { h := newHelper() acct, err := h.account() if err != nil { @@ -111,7 +129,7 @@ func archive(msgs []*models.MessageInfo, archiveType string) error { success := true for dir, uids := range uidMap { - store.Move(uids, dir, true, func( + store.Move(uids, dir, true, mfs, func( msg types.WorkerMessage, ) { switch msg := msg.(type) { diff --git a/commands/msg/copy.go b/commands/msg/copy.go index af44216f..9ea81055 100644 --- a/commands/msg/copy.go +++ b/commands/msg/copy.go @@ -14,9 +14,10 @@ import ( ) type Copy struct { - CreateFolders bool `opt:"-p"` - Account string `opt:"-a" complete:"CompleteAccount"` - Folder string `opt:"folder" complete:"CompleteFolder"` + CreateFolders bool `opt:"-p"` + Account string `opt:"-a" complete:"CompleteAccount"` + MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS"` + Folder string `opt:"folder" complete:"CompleteFolder"` } func init() { @@ -31,6 +32,17 @@ func (Copy) Aliases() []string { return []string{"cp", "copy"} } +func (c *Copy) ParseMFS(arg string) error { + if arg != "" { + mfs, ok := types.StrToStrategy[arg] + if !ok { + return fmt.Errorf("invalid multi-file strategy %s", arg) + } + c.MultiFileStrategy = &mfs + } + return nil +} + func (*Copy) CompleteAccount(arg string) []string { return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace) } @@ -48,6 +60,10 @@ func (c *Copy) CompleteFolder(arg string) []string { return commands.FilterList(acct.Directories().List(), arg, nil) } +func (Copy) CompleteMFS(arg string) []string { + return commands.FilterList(types.StrategyStrs(), arg, nil) +} + func (c Copy) Execute(args []string) error { h := newHelper() uids, err := h.markedOrSelectedUids() @@ -60,9 +76,10 @@ func (c Copy) Execute(args []string) error { } if len(c.Account) == 0 { - store.Copy(uids, c.Folder, c.CreateFolders, func(msg types.WorkerMessage) { - c.CallBack(msg, uids, store) - }) + store.Copy(uids, c.Folder, c.CreateFolders, c.MultiFileStrategy, + func(msg types.WorkerMessage) { + c.CallBack(msg, uids, store) + }) return nil } diff --git a/commands/msg/delete.go b/commands/msg/delete.go index 0abde31c..0d269eab 100644 --- a/commands/msg/delete.go +++ b/commands/msg/delete.go @@ -13,7 +13,9 @@ import ( "git.sr.ht/~rjarry/aerc/worker/types" ) -type Delete struct{} +type Delete struct { + MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS"` +} func init() { commands.Register(Delete{}) @@ -27,7 +29,22 @@ func (Delete) Aliases() []string { return []string{"delete", "delete-message"} } -func (Delete) Execute(args []string) error { +func (d *Delete) ParseMFS(arg string) error { + if arg != "" { + mfs, ok := types.StrToStrategy[arg] + if !ok { + return fmt.Errorf("invalid multi-file strategy %s", arg) + } + d.MultiFileStrategy = &mfs + } + return nil +} + +func (Delete) CompleteMFS(arg string) []string { + return commands.FilterList(types.StrategyStrs(), arg, nil) +} + +func (d Delete) Execute(args []string) error { h := newHelper() store, err := h.store() if err != nil { @@ -46,7 +63,7 @@ func (Delete) Execute(args []string) error { marker.ClearVisualMark() // caution, can be nil next := findNextNonDeleted(uids, store) - store.Delete(uids, func(msg types.WorkerMessage) { + store.Delete(uids, d.MultiFileStrategy, func(msg types.WorkerMessage) { switch msg := msg.(type) { case *types.Done: var s string diff --git a/commands/msg/move.go b/commands/msg/move.go index d2623268..c073765f 100644 --- a/commands/msg/move.go +++ b/commands/msg/move.go @@ -17,9 +17,10 @@ import ( ) type Move struct { - CreateFolders bool `opt:"-p"` - Account string `opt:"-a" complete:"CompleteAccount"` - Folder string `opt:"folder" complete:"CompleteFolder"` + CreateFolders bool `opt:"-p"` + Account string `opt:"-a" complete:"CompleteAccount"` + MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS"` + Folder string `opt:"folder" complete:"CompleteFolder"` } func init() { @@ -34,6 +35,17 @@ func (Move) Aliases() []string { return []string{"mv", "move"} } +func (m *Move) ParseMFS(arg string) error { + if arg != "" { + mfs, ok := types.StrToStrategy[arg] + if !ok { + return fmt.Errorf("invalid multi-file strategy %s", arg) + } + m.MultiFileStrategy = &mfs + } + return nil +} + func (*Move) CompleteAccount(arg string) []string { return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace) } @@ -51,6 +63,10 @@ func (m *Move) CompleteFolder(arg string) []string { return commands.FilterList(acct.Directories().List(), arg, nil) } +func (Move) CompleteMFS(arg string) []string { + return commands.FilterList(types.StrategyStrs(), arg, nil) +} + func (m Move) Execute(args []string) error { h := newHelper() acct, err := h.account() @@ -71,9 +87,10 @@ func (m Move) Execute(args []string) error { marker.ClearVisualMark() if len(m.Account) == 0 { - store.Move(uids, m.Folder, m.CreateFolders, func(msg types.WorkerMessage) { - m.CallBack(msg, acct, uids, next, marker, false) - }) + store.Move(uids, m.Folder, m.CreateFolders, m.MultiFileStrategy, + func(msg types.WorkerMessage) { + m.CallBack(msg, acct, uids, next, marker, false) + }) return nil } @@ -158,7 +175,8 @@ func (m Move) Execute(args []string) error { } } if len(appended) > 0 { - store.Delete(appended, func(msg types.WorkerMessage) { + mfs := types.Refuse + store.Delete(appended, &mfs, func(msg types.WorkerMessage) { m.CallBack(msg, acct, appended, next, marker, timeout) }) } diff --git a/commands/msg/recall.go b/commands/msg/recall.go index 280c41b8..7c59ac85 100644 --- a/commands/msg/recall.go +++ b/commands/msg/recall.go @@ -75,6 +75,7 @@ func (r Recall) Execute(args []string) error { deleteMessage := func() { store.Delete( uids, + nil, func(msg types.WorkerMessage) { switch msg := msg.(type) { case *types.Done: diff --git a/commands/msg/reply.go b/commands/msg/reply.go index 02cf42ce..79a0c598 100644 --- a/commands/msg/reply.go +++ b/commands/msg/reply.go @@ -184,7 +184,7 @@ func (r reply) Execute(args []string) error { switch { case c.Sent() && c.Archive() != "" && !noStore: store.Answered([]uint32{msg.Uid}, true, nil) - err := archive([]*models.MessageInfo{msg}, c.Archive()) + err := archive([]*models.MessageInfo{msg}, nil, c.Archive()) if err != nil { app.PushStatus("Archive failed", 10*time.Second) } diff --git a/doc/aerc-notmuch.5.scd b/doc/aerc-notmuch.5.scd index 6837f720..202f20fd 100644 --- a/doc/aerc-notmuch.5.scd +++ b/doc/aerc-notmuch.5.scd @@ -68,9 +68,8 @@ options are available: N.B.: aerc will still always show messages and not files (under notmuch, a single message can be represented by several files), which makes the - semantics of certain commands as *move* ambiguous: for example, if you - try to move a message represented by several files, aerc will not know - what to do and thus refuse. + semantics of certain commands as *move* ambiguous. Use *multi-file-strategy* + to tell aerc how to resolve these ambiguities. *maildir-account-path* = _<path>_ Path to the maildir account relative to the *maildir-store*. @@ -78,6 +77,25 @@ options are available: This could be used to achieve traditional maildir one tab per account behavior. The note on *maildir-store* also applies to this option. +*multi-file-stategy* = _<strategy>_ + Strategy for file operations (e.g., move, copy, delete) on messages that are + backed by multiple files. Possible values: + + - *refuse* (default): Refuse to act. + - *act-all*: Act on all files. + - *act-one*: Act on one of the files, arbitrarily chosen, and ignore the + rest. + - *act-one-delete-rest*: Like *act-one*, but delete the remaining files. + - *act-dir*: Act on all files within the current folder and ignore the rest. + Note that this strategy only works within the maildir directories; in other + directories, it behaves like *refuse*. + - *act-dir-delete-rest*: Like *act-dir*, but delete the remaining files. + + Note that the strategy has no effect on cross-account operations. Copying a + message across accounts will always copy a single file, arbitrarily chosen. + Moving a message across accounts will always copy a single file, arbitrarily + chosen, and refuse to delete multiple files from the source account. + # USAGE Notmuch shows slightly different behavior than for example imap. Some commands diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd index af1ce31c..3f52fbac 100644 --- a/doc/aerc.1.scd +++ b/doc/aerc.1.scd @@ -265,7 +265,7 @@ These commands work in any context. These commands are valid in any context that has a selected message (e.g. the message list, the message in the message viewer, etc). -*:archive* _<scheme>_ +*:archive* [*-m* _<strategy>_] _<scheme>_ Moves the selected message to the archive. The available schemes are: _flat_: No special structure, all messages in the archive directory @@ -274,6 +274,9 @@ message list, the message in the message viewer, etc). _month_: Messages are stored in folders per year and subfolders per month + The *-m* option sets the multi-file strategy. See *aerc-notmuch*(5) for more + details. + *:accept* [*-e*|*-E*] Accepts an iCalendar meeting invitation. @@ -288,8 +291,8 @@ message list, the message in the message viewer, etc). *-E*: Forces *[compose].edit-headers* = _false_ for this message only. -*:copy* [*-p*] [*-a* _<account>_] _<folder>_++ -*:cp* [*-p*] [*-a* _<account>_] _<folder>_ +*:copy* [*-p*] [*-a* _<account>_] [*-m* _<strategy>_] _<folder>_++ +*:cp* [*-p*] [*-a* _<account>_] [*-m* _<strategy>_] _<folder>_ Copies the selected message(s) to _<folder>_. *-p*: Create _<folder>_ if it does not exist. @@ -297,6 +300,8 @@ message list, the message in the message viewer, etc). *-a*: Copy to _<folder>_ of _<account>_. If _<folder>_ does not exist, it will be created whether or not *-p* is used. + *-m*: Set the multi-file strategy. See *aerc-notmuch*(5) for more details. + *:decline* [*-e*|*-E*] Declines an iCalendar meeting invitation. @@ -304,10 +309,12 @@ message list, the message in the message viewer, etc). *-E*: Forces *[compose].edit-headers* = _false_ for this message only. -*:delete*++ -*:delete-message* +*:delete* [*-m* _<strategy>_]++ +*:delete-message* [*-m* _<strategy>_] Deletes the selected message. + *-m*: Set the multi-file strategy. See *aerc-notmuch*(5) for more details. + *:envelope* [*-h*] [*-s* _<format-specifier>_] Opens the message envelope in a dialog popup. @@ -351,8 +358,8 @@ message list, the message in the message viewer, etc). *-E*: Forces *[compose].edit-headers* = _false_ for this message only. -*:move* [*-p*] [*-a* _<account>_] _<folder>_++ -*:mv* [*-p*] [*-a* _<account>_] _<folder>_ +*:move* [*-p*] [*-a* _<account>_] [*-m* _<strategy>_] _<folder>_++ +*:mv* [*-p*] [*-a* _<account>_] [*-m* _<strategy>_] _<folder>_ Moves the selected message(s) to _<folder>_. *-p*: Create _<folder>_ if it does not exist. @@ -360,6 +367,8 @@ message list, the message in the message viewer, etc). *-a*: Move to _<folder>_ of _<account>_. If _<folder>_ does not exist, it will be created whether or not *-p* is used. + *-m*: Set the multi-file strategy. See *aerc-notmuch*(5) for more details. + *:patch* _<args ...>_ Patch management sub-commands. See *aerc-patch*(7) for more details. diff --git a/lib/msgstore.go b/lib/msgstore.go index 274e42ba..d11d280e 100644 --- a/lib/msgstore.go +++ b/lib/msgstore.go @@ -572,14 +572,14 @@ func (store *MessageStore) doThreadFolding(uid uint32, hide bool, toggle bool) e return nil } -func (store *MessageStore) Delete(uids []uint32, +func (store *MessageStore) Delete(uids []uint32, mfs *types.MultiFileStrategy, cb func(msg types.WorkerMessage), ) { for _, uid := range uids { store.Deleted[uid] = nil } - store.worker.PostAction(&types.DeleteMessages{Uids: uids}, + store.worker.PostAction(&types.DeleteMessages{Uids: uids, MultiFileStrategy: mfs}, func(msg types.WorkerMessage) { if _, ok := msg.(*types.Error); ok { store.revertDeleted(uids) @@ -601,7 +601,7 @@ func (store *MessageStore) revertDeleted(uids []uint32) { } func (store *MessageStore) Copy(uids []uint32, dest string, createDest bool, - cb func(msg types.WorkerMessage), + mfs *types.MultiFileStrategy, cb func(msg types.WorkerMessage), ) { if createDest { store.worker.PostAction(&types.CreateDirectory{ @@ -611,8 +611,9 @@ func (store *MessageStore) Copy(uids []uint32, dest string, createDest bool, } store.worker.PostAction(&types.CopyMessages{ - Destination: dest, - Uids: uids, + Destination: dest, + Uids: uids, + MultiFileStrategy: mfs, }, func(msg types.WorkerMessage) { if _, ok := msg.(*types.Done); ok { store.triggerMailAdded(dest) @@ -622,7 +623,7 @@ func (store *MessageStore) Copy(uids []uint32, dest string, createDest bool, } func (store *MessageStore) Move(uids []uint32, dest string, createDest bool, - cb func(msg types.WorkerMessage), + mfs *types.MultiFileStrategy, cb func(msg types.WorkerMessage), ) { for _, uid := range uids { store.Deleted[uid] = nil @@ -636,8 +637,9 @@ func (store *MessageStore) Move(uids []uint32, dest string, createDest bool, } store.worker.PostAction(&types.MoveMessages{ - Destination: dest, - Uids: uids, + Destination: dest, + Uids: uids, + MultiFileStrategy: mfs, }, func(msg types.WorkerMessage) { switch msg.(type) { case *types.Error: diff --git a/worker/notmuch/message.go b/worker/notmuch/message.go index 09850d64..daaec7d0 100644 --- a/worker/notmuch/message.go +++ b/worker/notmuch/message.go @@ -17,6 +17,7 @@ import ( "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/worker/lib" notmuch "git.sr.ht/~rjarry/aerc/worker/notmuch/lib" + "git.sr.ht/~rjarry/aerc/worker/types" ) type Message struct { @@ -173,87 +174,144 @@ func (m *Message) ModifyTags(add, remove []string) error { return m.db.MsgModifyTags(m.key, add, remove) } -func (m *Message) Remove(dir maildir.Dir) error { - filenames, err := m.db.MsgFilenames(m.key) +func (m *Message) Remove(curDir maildir.Dir, mfs types.MultiFileStrategy) error { + rm, del, err := m.filenamesForStrategy(mfs, curDir) if err != nil { return err } - for _, filename := range filenames { - if dirContains(dir, filename) { - err := m.db.DeleteMessage(filename) - if err != nil { - return err - } - if err := os.Remove(filename); err != nil { - return err - } - - return nil - } - } - - return fmt.Errorf("no matching message file found in %s", string(dir)) + rm = append(rm, del...) + return m.deleteFiles(rm) } -func (m *Message) Copy(target maildir.Dir) error { - filename, err := m.Filename() +func (m *Message) Copy(curDir, destDir maildir.Dir, mfs types.MultiFileStrategy) error { + cp, del, err := m.filenamesForStrategy(mfs, curDir) if err != nil { return err } - source, key := parseFilename(filename) - if key == "" { - return fmt.Errorf("failed to parse message filename: %s", filename) - } + for _, filename := range cp { + source, key := parseFilename(filename) + if key == "" { + return fmt.Errorf("failed to parse message filename: %s", filename) + } - newKey, err := source.Copy(target, key) - if err != nil { - return err + newKey, err := source.Copy(destDir, key) + if err != nil { + return err + } + newFilename, err := destDir.Filename(newKey) + if err != nil { + return err + } + _, err = m.db.IndexFile(newFilename) + if err != nil { + return err + } } - newFilename, err := target.Filename(newKey) + + return m.deleteFiles(del) +} + +func (m *Message) Move(curDir, destDir maildir.Dir, mfs types.MultiFileStrategy) error { + move, del, err := m.filenamesForStrategy(mfs, curDir) if err != nil { return err } - _, err = m.db.IndexFile(newFilename) - return err -} -func (m *Message) Move(srcDir, destDir maildir.Dir) error { - var src string + for _, filename := range move { + // Remove encoded UID information from the key to prevent sync issues + name := lib.StripUIDFromMessageFilename(filepath.Base(filename)) + dest := filepath.Join(string(destDir), "cur", name) - filenames, err := m.db.MsgFilenames(m.key) - if err != nil { - return err + if err := os.Rename(filename, dest); err != nil { + return err + } + + if _, err = m.db.IndexFile(dest); err != nil { + return err + } + + if err := m.db.DeleteMessage(filename); err != nil { + return err + } } + + return m.deleteFiles(del) +} + +func (m *Message) deleteFiles(filenames []string) error { for _, filename := range filenames { - if dirContains(srcDir, filename) { - src = filename - break + if err := os.Remove(filename); err != nil { + return err + } + + if err := m.db.DeleteMessage(filename); err != nil { + return err } } - if src == "" { - return fmt.Errorf("no matching message file found in %s", string(srcDir)) + return nil +} + +func (m *Message) filenamesForStrategy(strategy types.MultiFileStrategy, + curDir maildir.Dir, +) (act, del []string, err error) { + filenames, err := m.db.MsgFilenames(m.key) + if err != nil { + return nil, nil, err } + return filterForStrategy(filenames, strategy, curDir) +} - // Remove encoded UID information from the key to prevent sync issues - name := lib.StripUIDFromMessageFilename(filepath.Base(src)) - dest := filepath.Join(string(destDir), "cur", name) +func filterForStrategy(filenames []string, strategy types.MultiFileStrategy, + curDir maildir.Dir, +) (act, del []string, err error) { + if curDir == "" && + (strategy == types.ActDir || strategy == types.ActDirDelRest) { + strategy = types.Refuse + } - if err := os.Rename(src, dest); err != nil { - return err + if len(filenames) < 2 { + return filenames, []string{}, nil } - if _, err = m.db.IndexFile(dest); err != nil { - return err + act = []string{} + rest := []string{} + switch strategy { + case types.Refuse: + return nil, nil, fmt.Errorf("refusing to act on multiple files") + case types.ActAll: + act = filenames + case types.ActOne: + fallthrough + case types.ActOneDelRest: + act = filenames[:1] + rest = filenames[1:] + case types.ActDir: + fallthrough + case types.ActDirDelRest: + for _, filename := range filenames { + if filepath.Dir(filepath.Dir(filename)) == string(curDir) { + act = append(act, filename) + } else { + rest = append(rest, filename) + } + } + default: + return nil, nil, fmt.Errorf("invalid multi-file strategy %v", strategy) } - if err := m.db.DeleteMessage(src); err != nil { - return err + switch strategy { + case types.ActOneDelRest: + fallthrough + case types.ActDirDelRest: + del = rest + default: + del = []string{} } - return nil + return act, del, nil } func parseFilename(filename string) (maildir.Dir, string) { @@ -270,13 +328,3 @@ func parseFilename(filename string) (maildir.Dir, string) { key := split[0] return maildir.Dir(dir), key } - -func dirContains(dir maildir.Dir, filename string) bool { - for _, sub := range []string{"cur", "new"} { - match, _ := filepath.Match(filepath.Join(string(dir), sub, "*"), filename) - if match { - return true - } - } - return false -} diff --git a/worker/notmuch/message_test.go b/worker/notmuch/message_test.go new file mode 100644 index 00000000..51fcdb09 --- /dev/null +++ b/worker/notmuch/message_test.go @@ -0,0 +1,264 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +import ( + "testing" + + "git.sr.ht/~rjarry/aerc/worker/types" + "github.com/emersion/go-maildir" +) + +func TestFilterForStrategy(t *testing.T) { + tests := []struct { + filenames []string + strategy types.MultiFileStrategy + curDir string + expectedAct []string + expectedDel []string + expectedErr bool + }{ + // if there's only one file, always act on it + { + filenames: []string{"/h/j/m/A/cur/a.b.c:2,"}, + strategy: types.Refuse, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{"/h/j/m/A/cur/a.b.c:2,"}, + strategy: types.ActAll, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{"/h/j/m/A/cur/a.b.c:2,"}, + strategy: types.ActOne, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{"/h/j/m/A/cur/a.b.c:2,"}, + strategy: types.ActOneDelRest, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{"/h/j/m/A/cur/a.b.c:2,"}, + strategy: types.ActDir, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{"/h/j/m/A/cur/a.b.c:2,"}, + strategy: types.ActDirDelRest, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + + // follow strategy for multiple files + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.Refuse, + curDir: "/h/j/m/B", + expectedErr: true, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActAll, + curDir: "/h/j/m/B", + expectedAct: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + expectedDel: []string{}, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActOne, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActOneDelRest, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{ + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActDir, + curDir: "/h/j/m/B", + expectedAct: []string{ + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + }, + expectedDel: []string{}, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActDirDelRest, + curDir: "/h/j/m/B", + expectedAct: []string{ + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + }, + expectedDel: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/C/new/d.e.f", + }, + }, + + // refuse to act on multiple files for ActDir and friends if + // no current dir is provided + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActDir, + curDir: "", + expectedErr: true, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActDirDelRest, + curDir: "", + expectedErr: true, + }, + + // act on multiple files w/o current dir for other strategies + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActAll, + curDir: "", + expectedAct: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + expectedDel: []string{}, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActOne, + curDir: "", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActOneDelRest, + curDir: "", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{ + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + }, + } + + for i, test := range tests { + act, del, err := filterForStrategy(test.filenames, test.strategy, + maildir.Dir(test.curDir)) + + if test.expectedErr && err == nil { + t.Errorf("[test %d] got nil, expected error", i) + } + + if !test.expectedErr && err != nil { + t.Errorf("[test %d] got %v, expected nil", i, err) + } + + if !arrEq(act, test.expectedAct) { + t.Errorf("[test %d] got %v, expected %v", i, act, test.expectedAct) + } + + if !arrEq(del, test.expectedDel) { + t.Errorf("[test %d] got %v, expected %v", i, del, test.expectedDel) + } + } +} + +func arrEq(a, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} diff --git a/worker/notmuch/worker.go b/worker/notmuch/worker.go index aa2da391..fe41446e 100644 --- a/worker/notmuch/worker.go +++ b/worker/notmuch/worker.go @@ -55,6 +55,7 @@ type worker struct { headers []string headersExclude []string state uint64 + mfs types.MultiFileStrategy } // NewWorker creates a new notmuch worker with the provided worker. @@ -243,6 +244,16 @@ func (w *worker) handleConfigure(msg *types.Configure) error { w.headers = msg.Config.Headers w.headersExclude = msg.Config.HeadersExclude + mfs := msg.Config.Params["multi-file-strategy"] + if mfs != "" { + w.mfs, ok = types.StrToStrategy[mfs] + if !ok { + return fmt.Errorf("invalid multi-file strategy %s", mfs) + } + } else { + w.mfs = types.Refuse + } + return nil } @@ -755,17 +766,12 @@ func (w *worker) handleDeleteMessages(msg *types.DeleteMessages) error { var deleted []uint32 - // With notmuch, two identical files can be referenced under - // the same index key, even if they exist in two different - // folders. So in order to remove the message from the right - // maildir folder we need to pass a hint to Remove() so it - // can purge the right file. folders, _ := w.store.FolderMap() - path, ok := folders[w.currentQueryName] - if !ok { - w.err(msg, fmt.Errorf("Can only delete file from a maildir folder")) - w.done(msg) - return nil + curDir := folders[w.currentQueryName] + + mfs := w.mfs + if msg.MultiFileStrategy != nil { + mfs = *msg.MultiFileStrategy } for _, uid := range msg.Uids { @@ -775,7 +781,7 @@ func (w *worker) handleDeleteMessages(msg *types.DeleteMessages) error { w.err(msg, err) continue } - if err := m.Remove(path); err != nil { + if err := m.Remove(curDir, mfs); err != nil { w.w.Errorf("could not remove message: %v", err) w.err(msg, err) continue @@ -804,13 +810,20 @@ func (w *worker) handleCopyMessages(msg *types.CopyMessages) error { return fmt.Errorf("Can only copy file to a maildir folder") } + curDir := folders[w.currentQueryName] + + mfs := w.mfs + if msg.MultiFileStrategy != nil { + mfs = *msg.MultiFileStrategy + } + for _, uid := range msg.Uids { m, err := w.msgFromUid(uid) if err != nil { w.w.Errorf("could not get message: %v", err) return err } - if err := m.Copy(dest); err != nil { + if err := m.Copy(curDir, dest, mfs); err != nil { w.w.Errorf("could not copy message: %v", err) return err } @@ -839,6 +852,13 @@ func (w *worker) handleMoveMessages(msg *types.MoveMessages) error { return fmt.Errorf("Can only move file to a maildir folder") } + curDir := folders[w.currentQueryName] + + mfs := w.mfs + if msg.MultiFileStrategy != nil { + mfs = *msg.MultiFileStrategy + } + var err error for _, uid := range msg.Uids { m, err := w.msgFromUid(uid) @@ -846,22 +866,8 @@ func (w *worker) handleMoveMessages(msg *types.MoveMessages) error { w.w.Errorf("could not get message: %v", err) break } - filenames, err := m.db.MsgFilenames(m.key) - if err != nil { - return err - } - // In the future, it'd be nice if we could overload move with - // the possibility to affect some or all of the files - // corresponding to a message. - if len(filenames) > 1 { - return fmt.Errorf("Cannot move: message %d has multiple files", m.uid) - } - source, key := parseFilename(filenames[0]) - if key == "" { - return fmt.Errorf("failed to parse message filename: %s", filenames[0]) - } - if err := m.Move(source, dest); err != nil { - w.w.Errorf("could not copy message: %v", err) + if err := m.Move(curDir, dest, mfs); err != nil { + w.w.Errorf("could not move message: %v", err) break } moved = append(moved, uid) diff --git a/worker/types/messages.go b/worker/types/messages.go index 90d3d7bb..bbc430ca 100644 --- a/worker/types/messages.go +++ b/worker/types/messages.go @@ -167,7 +167,8 @@ type FetchMessageFlags struct { type DeleteMessages struct { Message - Uids []uint32 + Uids []uint32 + MultiFileStrategy *MultiFileStrategy } // Flag messages with different mail types @@ -186,14 +187,16 @@ type AnsweredMessages struct { type CopyMessages struct { Message - Destination string - Uids []uint32 + Destination string + Uids []uint32 + MultiFileStrategy *MultiFileStrategy } type MoveMessages struct { Message - Destination string - Uids []uint32 + Destination string + Uids []uint32 + MultiFileStrategy *MultiFileStrategy } type AppendMessage struct { diff --git a/worker/types/mfs.go b/worker/types/mfs.go new file mode 100644 index 00000000..071eda1d --- /dev/null +++ b/worker/types/mfs.go @@ -0,0 +1,33 @@ +package types + +// MultiFileStrategy represents a strategy for taking file-based actions (e.g., +// move, copy, delete) on messages that are represented by more than one file. +// These strategies are only used by the notmuch backend but are defined in this +// package to prevent import cycles. +type MultiFileStrategy uint + +const ( + Refuse MultiFileStrategy = iota + ActAll + ActOne + ActOneDelRest + ActDir + ActDirDelRest +) + +var StrToStrategy = map[string]MultiFileStrategy{ + "refuse": Refuse, + "act-all": ActAll, + "act-one": ActOne, + "act-one-delete-rest": ActOneDelRest, + "act-dir": ActDir, + "act-dir-delete-rest": ActDirDelRest, +} + +func StrategyStrs() []string { + strs := make([]string, len(StrToStrategy)) + for s := range StrToStrategy { + strs = append(strs, s) + } + return strs +} |