diff options
-rw-r--r-- | NEWS | 13 | ||||
-rw-r--r-- | README.md | 3 | ||||
-rw-r--r-- | USAGE.md (renamed from USAGE) | 104 | ||||
-rw-r--r-- | alot/account.py | 127 | ||||
-rw-r--r-- | alot/buffer.py | 3 | ||||
-rw-r--r-- | alot/command.py | 232 | ||||
-rw-r--r-- | alot/completion.py | 170 | ||||
-rw-r--r-- | alot/db.py | 4 | ||||
-rw-r--r-- | alot/defaults/alot.rc (renamed from data/example.full.rc) | 303 | ||||
-rw-r--r-- | alot/defaults/notmuch.rc | 3 | ||||
-rw-r--r-- | alot/helper.py | 31 | ||||
-rwxr-xr-x | alot/init.py | 29 | ||||
-rw-r--r-- | alot/message.py | 22 | ||||
-rw-r--r-- | alot/settings.py | 318 | ||||
-rw-r--r-- | alot/ui.py | 177 | ||||
-rw-r--r-- | alot/widgets.py | 88 | ||||
-rw-r--r-- | data/example.rc | 3 | ||||
-rwxr-xr-x | setup.py | 1 |
18 files changed, 894 insertions, 737 deletions
@@ -1,3 +1,16 @@ +0.12 + +* moved default location for config to ~/.config/alot/config +* hooks for pre/post editing and RE/FWD quotestrings +* recipient completion gives priority to abook of sender account +* smarter in-string-tabcompletion +* added ability to pipe messages/treads to custom shellcommands +* initial searchstring configurable in configfile +* non-blocking prompt/choice (new syntax for prompts!) +* fix attachment saving + +Thanks: Ruben Pollan, Luke Macken, Justus Winter + 0.11 This minor release is mostly bug fixes and some small features. @@ -9,9 +9,6 @@ the `docs` directory contains their sources. Do comment on the code or file issues! I'm curious what you think of it. You can talk to me in #notmuch@Freenode. -Be aware that the master branch is used only for releases and hotfixes, -the bleeding edge version sits in branch `development`!. -If you'd like to contribute, please make sure your patches can be applied to that branch. Current features include: ------------------------- @@ -6,54 +6,57 @@ to the prompt. Any commandline can be mapped by using the "MODE-maps" sections in the config file. These are the default keymaps: [global-maps] - $ = flush - : = prompt - ; = bufferlist @ = refresh I = search tag:inbox AND NOT tag:killed L = taglist + shift tab = bprevious U = search tag:unread - \ = prompt search + tab = bnext + \ = 'prompt search ' d = bclose + $ = flush m = compose - o = prompt search + o = 'prompt search ' q = exit - shift tab = bprevious - tab = bnext - + ';' = bufferlist + colon = prompt + [bufferlist-maps] - enter = openfocussed x = closefocussed - + enter = openfocussed + [search-maps] + a = toggletag inbox & = toggletag killed + l = retagprompt O = refineprompt - a = toggletag inbox enter = openthread - l = retagprompt | = refineprompt - + [envelope-maps] - a = attach - enter = reedit - s = prompt subject - t = prompt to + a = prompt attach ~/ y = send - + s = 'prompt subject ' + t = 'prompt to ' + enter = reedit + [taglist-maps] enter = select - + [thread-maps] C = fold --all E = unfold --all H = toggleheaders - P = print --all + P = print --thread + S = save --all a = toggletag inbox - enter = select - f = forward g = groupreply + f = forward p = print + s = save r = reply + enter = select + | = 'prompt pipeto ' Config ------ @@ -61,8 +64,13 @@ Just like offlineimap or notmuch itself, alot reads a config file in the "INI" s It consists of some sections whose names are given in square brackets, followed by key-value pairs that use "=" or ":" as separator, ';' and '#' are comment-prefixes. -The default location for the config file is `~/.alot.rc`. -You can find a complete example config in `data/example.full.rc`. +The default location for the config file is `~/.config/alot/config`. +You can find a complete example config with the default values in +`alot/defaults/alot.rc`. + +Note that since ":" is a separator for key-value pairs you need to use "colon" to bind +commands to ":". + Here is a key for the interpreted sections: [general] @@ -107,6 +115,8 @@ I use this for my uni-account: draft_box = maildir:///home/pazz/mail/uoe/Drafts signature = ~/my_uni_vcard.vcs signature_filename = p.totzke.vcs + abook_command = abook --mutt-query + Caution: Sending mails is only supported via sendmail for now. If you want to use a sendmail command different from `sendmail`, specify it as `sendmail_command`. @@ -118,19 +128,27 @@ in the protocol part of the url. The file specified by `signature` is attached to all outgoing mails from this account, optionally renamed to `signature_filename`. +If you specified `abook_command`, it will be used for tab completion in queries (to/from) +and in message composition. The command will be called with your prefix as only argument +and its output is searched for name-email pairs. The regular expression used here +defaults to `(?P<email>.+?@.+?)\s+(?P<name>.+)`, which makes it work nicely with `abook --mutt-query`. +You can tune this using the `abook_regexp` option (beware Commandparsers escaping semantic!). + + Hooks ----- -Before and after every command execution, alot calls this commands pre/post hook: -Hooks are python callables with arity 4 that live in a module specified by -`hooksfile` in the `[global]` section of your config. Per default this points to `~/.alot.py` +Hooks are python callables that live in a module specified by +`hooksfile` in the `[global]` section of your config. Per default this points +to `~/.config/alot/hooks.py`. For every command X, the callable 'pre_X' will be called before X and 'post_X' afterwards. When a hook gets called, it receives instances of -1. `alot.ui.UI`, the main user interface object that can prompt etc. -2. `alot.db.DBManager`, the applications database manager -3. `alot.account.AccountManager`, can be used to look up account info -4. `alot.settings.config`, a configparser to access the users config + * ui: `alot.ui.UI`, the main user interface object that can prompt etc. + * dbm: `alot.db.DBManager`, the applications database manager + * aman: `alot.account.AccountManager`, can be used to look up account info + * log: `logging.Logger`, to write to logfile + * config: `alot.settings.config`, a configparser to access the users config An autogenerated API doc for these can be found at http://pazz.github.com/alot/ , the sphinx sources live in the `docs` folder. @@ -138,14 +156,30 @@ As an example, consider this pre-hook for the exit command, that logs a personalized goodby message: ```python -def pre_exit(ui, dbman, accountman, config): - accounts = accountman.get_accounts() +def pre_exit(aman=None, log=None, **rest): + accounts = aman.get_accounts() if accounts: - ui.logger.info('goodbye, %s!' % accounts[0].realname) + log.info('goodbye, %s!' % accounts[0].realname) else: - ui.logger.info('goodbye!') + log.info('goodbye!') ``` +Apart from command pre and posthooks, the following hooks will be interpreted: + * `reply_prefix(realname, address, timestamp, **kwargs)` + Is used to reformat the first indented line in a reply message. + Should return a string and defaults to 'Quoting %s (%s)\n' % (realname, timestamp) + + * `forward_prefix(realname, address, timestamp, **kwargs)` + Is used to reformat the first indented line in a inline forwarded message. + Returns a string and defaults to 'Forwarded message from %s (%s)\n' % (realname, timestamp) + * `pre_edit_translate(bodytext, **kwargs)` + can be used to manipulate a messages bodytext before the editor is called. + Receives and returns a string. + * `post_edit_translate(bodytext, **kwargs)` + can be used to manipulate a messages bodytext after the editor is called + Receives and returns a string. + + Theming ------- diff --git a/alot/account.py b/alot/account.py index 13bed02b..91ef5fff 100644 --- a/alot/account.py +++ b/alot/account.py @@ -22,16 +22,23 @@ import shlex import subprocess import logging import time +import re import email +import os +from ConfigParser import SafeConfigParser from urlparse import urlparse +from helper import cmd_output +import helper -class Account: + +class Account(object): """ Datastructure that represents an email account. It manages this account's settings, can send and store mails to maildirs (drafts/send) """ + address = None """this accounts main email address""" aliases = [] @@ -41,14 +48,18 @@ class Account: gpg_key = None """gpg fingerprint. CURRENTLY IGNORED""" signature = None - """path to a signature file to append to outgoing mails.""" + """signature to append to outgoing mails""" signature_filename = None """filename of signature file in attachment""" + abook = None + """addressbook""" def __init__(self, address=None, aliases=None, realname=None, gpg_key=None, - signature=None, signature_filename=None, sent_box=None, - draft_box=None): + signature=None, signature_filename=None, sent_box=None, + draft_box=None, abook=None): + self.address = address + self.abook = abook self.aliases = [] if aliases: self.aliases = aliases.split(';') @@ -141,23 +152,14 @@ class SendmailAccount(Account): def send_mail(self, mail): mail['Date'] = email.utils.formatdate(time.time(), True) - # no unicode in shlex on 2.x - args = shlex.split(self.cmd.encode('ascii')) - try: - proc = subprocess.Popen(args, stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - out, err = proc.communicate(mail.as_string()) - except OSError, e: - return str(e) + '. sendmail_cmd set to: %s' % self.cmd - if proc.poll(): # returncode is not 0 - return err.strip() - else: - self.store_sent_mail(mail) - return None + out, err = helper.pipe_to_command(self.cmd, mail.as_string()) + if err: + return err + '. sendmail_cmd set to: %s' % self.cmd + self.store_sent_mail(mail) + return None -class AccountManager: +class AccountManager(object): """Easy access to all known accounts""" allowed = ['realname', 'address', @@ -167,6 +169,8 @@ class AccountManager: 'signature_filename', 'type', 'sendmail_command', + 'abook_command', + 'abook_regexp', 'sent_box', 'draft_box'] manditory = ['realname', 'address'] @@ -179,7 +183,19 @@ class AccountManager: accountsections = filter(lambda s: s.startswith('account '), sections) for s in accountsections: options = filter(lambda x: x in self.allowed, config.options(s)) + args = {} + if 'abook_command' in options: + cmd = config.get(s, 'abook_command').encode('ascii', + errors='ignore') + options.remove('abook_command') + if 'abook_regexp' in options: + regexp = config.get(s, 'abook_regexp') + options.remove('abook_regexp') + else: + regexp = None # will use default in constructor + args['abook'] = MatchSdtoutAddressbook(cmd, match=regexp) + to_set = self.manditory for o in options: args[o] = config.get(s, o) @@ -212,8 +228,9 @@ class AccountManager: :rtype: `account.Account` or None """ - if address in self.accountmap: - return self.accountmap[address] + for myad in self.get_addresses(): + if myad in address: + return self.accountmap[myad] else: return None # log info @@ -225,3 +242,71 @@ class AccountManager: def get_addresses(self): """returns addresses of known accounts including all their aliases""" return self.accountmap.keys() + + def get_addressbooks(self, order=[], append_remaining=True): + abooks = [] + for a in order: + if a: + if a.abook: + abooks.append(a.abook) + if append_remaining: + for a in self.accounts: + if a.abook and a.abook not in abooks: + abooks.append(a.abook) + return abooks + + +class AddressBook(object): + def get_contacts(self): + return [] + + def lookup(self, prefix=''): + res = [] + for name, email in self.get_contacts(): + if name.startswith(prefix) or email.startswith(prefix): + res.append("%s <%s>" % (name, email)) + return res + + +class AbookAddressBook(AddressBook): + def __init__(self, config=None): + self.abook = SafeConfigParser() + if not config: + config = os.environ["HOME"] + "/.abook/addressbook" + self.abook.read(config) + + def get_contacts(self): + res = [] + for s in self.abook.sections(): + if s.isdigit(): + name = self.abook.get(s, 'name') + email = self.abook.get(s, 'email') + res.append((name, email)) + return res + + +class MatchSdtoutAddressbook(AddressBook): + def __init__(self, command, match=None): + self.command = command + if not match: + self.match = "(?P<email>.+?@.+?)\s+(?P<name>.+)" + else: + self.match = match + + def get_contacts(self): + return self.lookup('\'\'') + + def lookup(self, prefix): + resultstring = cmd_output('%s %s' % (self.command, prefix)) + if not resultstring: + return [] + lines = resultstring.replace('\t', ' ' * 4).splitlines() + res = [] + for l in lines: + m = re.match(self.match, l) + if m: + info = m.groupdict() + email = info['email'].strip() + name = info['name'].strip() + res.append((name, email)) + return res diff --git a/alot/buffer.py b/alot/buffer.py index e44db1bd..77eef99e 100644 --- a/alot/buffer.py +++ b/alot/buffer.py @@ -27,7 +27,7 @@ from walker import IteratorWalker from message import decode_header -class Buffer: +class Buffer(object): def __init__(self, ui, widget, name): self.ui = ui self.typename = name @@ -206,6 +206,7 @@ class ThreadBuffer(Buffer): self._build_pile(acc, reply, msg, depth + 1) def rebuild(self): + self.thread.refresh() # depth-first traversing the thread-tree, thereby # 1) build a list of tuples (parentmsg, depth, message) in DF order # 2) create a dict that counts no. of direct replies per message diff --git a/alot/command.py b/alot/command.py index af5f3308..e491d583 100644 --- a/alot/command.py +++ b/alot/command.py @@ -34,6 +34,7 @@ from email.message import Message from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart import urwid +from twisted.internet import reactor, defer import buffer import settings @@ -49,7 +50,7 @@ from message import decode_header from message import encode_header -class Command: +class Command(object): """base class for commands""" def __init__(self, prehook=None, posthook=None): self.prehook = prehook @@ -63,11 +64,13 @@ class Command: class ExitCommand(Command): """shuts the MUA down cleanly""" + @defer.inlineCallbacks def apply(self, ui): if settings.config.getboolean('general', 'bug_on_exit'): - if not ui.choice('realy quit?', choices={'yes': ['y', 'enter'], - 'no': ['n']}) == 'yes': + if (yield ui.choice('realy quit?', select='yes', cancel='no', + msg_position='left')) == 'no': return + reactor.stop() raise urwid.ExitMainLoop() @@ -98,11 +101,12 @@ class SearchCommand(Command): self.query = query Command.__init__(self, **kwargs) + @defer.inlineCallbacks def apply(self, ui): if self.query: if self.query == '*' and ui.current_buffer: s = 'really search for all threads? This takes a while..' - if not ui.choice(s) == 'yes': + if (yield ui.choice(s, select='yes', cancel='no')) == 'no': return open_searches = ui.get_buffers_of_type(buffer.SearchBuffer) to_be_focused = None @@ -136,11 +140,13 @@ class RefreshCommand(Command): class ExternalCommand(Command): """calls external command""" - def __init__(self, commandstring, spawn=False, refocus=True, + def __init__(self, commandstring, path=None, spawn=False, refocus=True, in_thread=False, on_success=None, **kwargs): """ :param commandstring: the command to call :type commandstring: str + :param path: a path to a file (or None) + :type path: str :param spawn: run command in a new terminal :type spawn: boolean :param in_thread: run asynchronously, don't block alot @@ -151,6 +157,7 @@ class ExternalCommand(Command): :type on_success: callable """ self.commandstring = commandstring + self.path = path self.spawn = spawn self.refocus = refocus self.in_thread = in_thread @@ -170,13 +177,23 @@ class ExternalCommand(Command): write_fd = ui.mainloop.watch_pipe(afterwards) def thread_code(*args): - cmd = self.commandstring + if self.path: + if '{}' in self.commandstring: + cmd = self.commandstring.replace('{}', + helper.shell_quote(self.path)) + else: + cmd = '%s %s' % (self.commandstring, + helper.shell_quote(self.path)) + else: + cmd = self.commandstring + if self.spawn: cmd = '%s %s' % (settings.config.get('general', 'terminal_cmd'), cmd) + cmd = cmd.encode('ascii', errors='ignore') ui.logger.info('calling external command: %s' % cmd) - returncode = subprocess.call(cmd, shell=True) + returncode = subprocess.call(shlex.split(cmd)) if returncode == 0: os.write(write_fd, 'success') @@ -197,9 +214,9 @@ class EditCommand(ExternalCommand): else: self.spawn = settings.config.getboolean('general', 'spawn_editor') editor_cmd = settings.config.get('general', 'editor_cmd') - cmd = editor_cmd + ' ' + self.path - ExternalCommand.__init__(self, cmd, spawn=self.spawn, - in_thread=self.spawn, + + ExternalCommand.__init__(self, editor_cmd, path=self.path, + spawn=self.spawn, in_thread=self.spawn, **kwargs) @@ -354,6 +371,7 @@ class ComposeCommand(Command): for key, value in headers.items(): self.mail[key] = encode_header(key, value) + @defer.inlineCallbacks def apply(self, ui): # TODO: fill with default header (per account) # get From header @@ -366,11 +384,13 @@ class ComposeCommand(Command): a = accounts[0] else: cmpl = AccountCompleter(ui.accountman) - fromaddress = ui.prompt(prefix='From>', completer=cmpl, tab=1) + fromaddress = yield ui.prompt(prefix='From>', completer=cmpl, + tab=1) validaddresses = [a.address for a in accounts] + [None] while fromaddress not in validaddresses: # TODO: not cool ui.notify('no account for this address. (<esc> cancels)') - fromaddress = ui.prompt(prefix='From>', completer=cmpl) + fromaddress = yield ui.prompt(prefix='From>', + completer=cmpl) if not fromaddress: ui.notify('canceled') return @@ -379,14 +399,24 @@ class ComposeCommand(Command): #get To header if 'To' not in self.mail: - to = ui.prompt(prefix='To>', completer=ContactsCompleter()) + name, addr = email.Utils.parseaddr(unicode(self.mail.get('From'))) + a = ui.accountman.get_account_by_address(addr) + + allbooks = not settings.config.getboolean('general', + 'complete_matching_abook_only') + ui.logger.debug(allbooks) + abooks = ui.accountman.get_addressbooks(order=[a], + append_remaining=allbooks) + ui.logger.debug(abooks) + to = yield ui.prompt(prefix='To>', + completer=ContactsCompleter(abooks)) if to == None: ui.notify('canceled') return self.mail['To'] = encode_header('to', to) if settings.config.getboolean('general', 'ask_subject') and \ not 'Subject' in self.mail: - subject = ui.prompt(prefix='Subject>') + subject = yield ui.prompt(prefix='Subject>') if subject == None: ui.notify('canceled') return @@ -444,11 +474,12 @@ class RefineCommand(Command): self.querystring = query Command.__init__(self, **kwargs) + @defer.inlineCallbacks def apply(self, ui): if self.querystring: if self.querystring == '*': s = 'really search for all threads? This takes a while..' - if not ui.choice(s) == 'yes': + if (yield ui.choice(s, select='yes', cancel='no')) == 'no': return sbuffer = ui.current_buffer oldquery = sbuffer.querystring @@ -488,8 +519,16 @@ class ReplyCommand(Command): self.message = ui.current_buffer.get_selected_message() mail = self.message.get_email() # set body text - mailcontent = '\nOn %s, %s wrote:\n' % (self.message.get_datestring(), - self.message.get_author()[0]) + name, address = self.message.get_author() + timestamp = self.message.get_date() + qf = settings.hooks.get('reply_prefix') + if qf: + quotestring = qf(name, address, timestamp, + ui=ui, dbm=ui.dbman, aman=ui.accountman, + log=ui.logger, config=settings.config) + else: + quotestring = 'Quoting %s (%s)\n' % (name, timestamp) + mailcontent = quotestring for line in self.message.accumulate_body().splitlines(): mailcontent += '>' + line + '\n' @@ -590,8 +629,16 @@ class ForwardCommand(Command): Charset.add_charset('utf-8', Charset.QP, Charset.QP, 'utf-8') if self.inline: # inline mode # set body text - author = self.message.get_author()[0] - mailcontent = '\nForwarded message from %s:\n' % author + name, address = self.message.get_author() + timestamp = self.message.get_date() + qf = settings.hooks.get('forward_prefix') + if qf: + quote = qf(name, address, timestamp, + ui=ui, dbm=ui.dbman, aman=ui.accountman, + log=ui.logger, config=settings.config) + else: + quote = 'Forwarded message from %s (%s):\n' % (name, timestamp) + mailcontent = quote for line in self.message.accumulate_body().splitlines(): mailcontent += '>' + line + '\n' @@ -660,30 +707,38 @@ class ToggleHeaderCommand(Command): msgw.toggle_full_header() -class PrintCommand(Command): - def __init__(self, all=False, separately=False, confirm=True, **kwargs): +class PipeCommand(Command): + def __init__(self, command, whole_thread=False, separately=False, + noop_msg='no command specified', confirm_msg='', + done_msg='done', **kwargs): Command.__init__(self, **kwargs) - self.all = all + self.cmd = command + self.whole_thread = whole_thread self.separately = separately - self.confirm = confirm + self.noop_msg = noop_msg + self.confirm_msg = confirm_msg + self.done_msg = done_msg + @defer.inlineCallbacks def apply(self, ui): - # get messages to print - if self.all: + # abort if command unset + if not self.cmd: + ui.notify(self.noop_msg, priority='error') + return + + # get messages to pipe + if self.whole_thread: thread = ui.current_buffer.get_selected_thread() if not thread: return to_print = thread.get_messages().keys() - confirm_msg = 'print all messages in thread?' - ok_msg = 'printed thread: %s' % str(thread) else: to_print = [ui.current_buffer.get_selected_message()] - confirm_msg = 'print this message?' - ok_msg = 'printed message: %s' % str(to_print[0]) # ask for confirmation if needed - if self.confirm: - if not ui.choice(confirm_msg) == 'yes': + if self.confirm_msg: + if (yield ui.choice(self.confirm_msg, select='yes', + cancel='no')) == 'no': return # prepare message sources @@ -691,26 +746,38 @@ class PrintCommand(Command): if not self.separately: mailstrings = ['\n\n'.join(mailstrings)] - # get print command - cmd = settings.config.get('general', 'print_cmd') - args = shlex.split(cmd.encode('ascii')) - - # print - try: - for mail in mailstrings: - proc = subprocess.Popen(args, stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - out, err = proc.communicate(mail) - if proc.poll(): # returncode is not 0 - raise OSError(err) - except OSError, e: # handle errors - ui.notify(str(e), priority='error') - return + # do teh monkey + for mail in mailstrings: + out, err = helper.pipe_to_command(self.cmd, mail) + if err: + ui.notify(err, priority='error') + return # display 'done' message - ui.notify(ok_msg) + if self.done_msg: + ui.notify(self.done_msg) + + +class PrintCommand(PipeCommand): + def __init__(self, whole_thread=False, separately=False, **kwargs): + # get print command + cmd = settings.config.get('general', 'print_cmd', fallback='') + + # set up notification strings + if whole_thread: + confirm_msg = 'print all messages in thread?' + ok_msg = 'printed thread using %s' % cmd + else: + confirm_msg = 'print selected message?' + ok_msg = 'printed message using %s' % cmd + # no print cmd set + noop_msg = 'no print command specified. Set "print_cmd" in the '\ + 'global section.' + PipeCommand.__init__(self, cmd, whole_thread=whole_thread, + separately=separately, + noop_msg=noop_msg, confirm_msg=confirm_msg, + done_msg=ok_msg, **kwargs) class SaveAttachmentCommand(Command): @@ -719,22 +786,27 @@ class SaveAttachmentCommand(Command): self.all = all self.path = path + @defer.inlineCallbacks def apply(self, ui): pcomplete = completion.PathCompleter() if self.all: msg = ui.current_buffer.get_selected_message() if not self.path: - self.path = ui.prompt(prefix='save attachments to:', + self.path = yield ui.prompt(prefix='save attachments to:', text=os.path.join('~', ''), completer=pcomplete) if self.path: - self.path = os.path.expanduser(self.path) - if os.path.isdir(self.path): + if os.path.isdir(os.path.expanduser(self.path)): for a in msg.get_attachments(): dest = a.save(self.path) - ui.notify('saved attachment as: %s' % dest) + name = a.get_filename() + if name: + ui.notify('saved %s as: %s' % (name, dest)) + else: + ui.notify('saved attachment as: %s' % dest) else: - ui.notify('not a directory: %s' % self.path) + ui.notify('not a directory: %s' % self.path, + priority='error') else: ui.notify('canceled') else: # save focussed attachment @@ -743,12 +815,17 @@ class SaveAttachmentCommand(Command): attachment = focus.get_attachment() filename = attachment.get_filename() if not self.path: - self.path = ui.prompt(prefix='save attachment as:', - text=os.path.join('~', filename), - completer=pcomplete) + msg = 'save attachment (%s) to:' % filename + initialtext = os.path.join('~', filename) + self.path = yield ui.prompt(prefix=msg, + completer=pcomplete, + text=initialtext) if self.path: - attachment.save(os.path.expanduser(self.path)) - ui.notify('saved attachment as: %s' % self.path) + try: + dest = attachment.save(self.path) + ui.notify('saved attachment as: %s' % dest) + except (IOError, OSError), e: + ui.notify(str(e), priority='error') else: ui.notify('canceled') @@ -766,9 +843,9 @@ class OpenAttachmentCommand(Command): if handler: path = self.attachment.save(tempfile.gettempdir()) if '%s' in handler: - cmd = handler % path.replace(' ', '\ ') + cmd = handler % path else: - cmd = '%s %s' % (handler, path.replace(' ', '\ ')) + cmd = '%s %s' % (handler, path) def afterwards(): os.remove(path) @@ -815,9 +892,9 @@ class EnvelopeEditCommand(Command): def openEnvelopeFromTmpfile(): # This parses the input from the tempfile. - # we do this ourselves here because we want to be able to - # just type utf-8 encoded stuff into the tempfile and let alot worry - # about encodings. + # we do this ourselves here because we want to be able to + # just type utf-8 encoded stuff into the tempfile and let alot + # worry about encodings. # get input f = open(tf.name) @@ -825,15 +902,22 @@ class EnvelopeEditCommand(Command): editor_input = f.read().decode(enc) headertext, bodytext = editor_input.split('\n\n', 1) + # call post-edit translate hook + translate = settings.hooks.get('post_edit_translate') + if translate: + bodytext = translate(bodytext, ui=ui, dbm=ui.dbman, + aman=ui.accountman, log=ui.logger, + config=settings.config) + # go through multiline, utf-8 encoded headers key = value = None for line in headertext.splitlines(): - if re.match('\w+:', line): #new k/v pair + if re.match('\w+:', line): # new k/v pair if key and value: # save old one from stack del self.mail[key] # ensure unique values in mails self.mail[key] = encode_header(key, value) # save key, value = line.strip().split(':', 1) # parse new pair - elif key and value: # append new line without key prefix to value + elif key and value: # append new line without key prefix value += line if key and value: # save last one if present del self.mail[key] @@ -871,6 +955,13 @@ class EnvelopeEditCommand(Command): else: bodytext = decode_to_unicode(self.mail) + # call pre-edit translate hook + translate = settings.hooks.get('pre_edit_translate') + if translate: + bodytext = translate(bodytext, ui=ui, dbm=ui.dbman, + aman=ui.accountman, log=ui.logger, + config=settings.config) + #write stuff to tempfile tf = tempfile.NamedTemporaryFile(delete=False) content = '%s\n\n%s' % (headertext, @@ -902,6 +993,7 @@ class EnvelopeSetCommand(Command): class EnvelopeSendCommand(Command): + @defer.inlineCallbacks def apply(self, ui): envelope = ui.current_buffer mail = envelope.get_email() @@ -921,7 +1013,8 @@ class EnvelopeSendCommand(Command): else: ui.notify('could not locate signature: %s' % sig, priority='error') - if not ui.choice('send without signature') == 'yes': + if (yield ui.choice('send without signature', + select='yes', cancel='no')) == 'no': return clearme = ui.notify('sending..', timeout=-1, block=False) @@ -1002,6 +1095,7 @@ COMMANDS = { 'groupreply': (ReplyCommand, {'groupreply': True}), 'forward': (ForwardCommand, {}), 'fold': (FoldMessagesCommand, {'visible': False}), + 'pipeto': (PipeCommand, {}), 'print': (PrintCommand, {}), 'unfold': (FoldMessagesCommand, {'visible': True}), 'select': (ThreadSelectCommand, {}), @@ -1050,6 +1144,7 @@ def commandfactory(cmdname, mode='global', **kwargs): def interpret_commandline(cmdline, mode): + # TODO: use argparser here! if not cmdline: return None logging.debug('mode:%s got commandline "%s"' % (mode, cmdline)) @@ -1120,8 +1215,11 @@ def interpret_commandline(cmdline, mode): return commandfactory(cmd, mode=mode, path=filepath) elif cmd == 'print': args = [a.strip() for a in params.split()] - return commandfactory(cmd, mode=mode, all=('--all' in args), + return commandfactory(cmd, mode=mode, + whole_thread=('--thread' in args), separately=('--separately' in args)) + elif cmd == 'pipeto': + return commandfactory(cmd, mode=mode, command=params) elif not params and cmd in ['exit', 'flush', 'pyshell', 'taglist', 'bclose', 'compose', 'openfocussed', diff --git a/alot/completion.py b/alot/completion.py index 716554c3..38d1eade 100644 --- a/alot/completion.py +++ b/alot/completion.py @@ -19,40 +19,71 @@ Copyright (C) 2011 Patrick Totzke <patricktotzke@gmail.com> import re import os +import glob +import logging import command -class Completer: - def complete(self, original): - """takes a string that's the prefix of a word, - returns a list of suffix-strings that complete the original""" +class Completer(object): + def complete(self, original, pos): + """returns a list of completions and cursor positions for the + string original from position pos on. + + :param original: the complete string to complete + :type original: str + :param pos: starting position to complete from + :returns: a list of tuples (ctext, cpos), where ctext is the completed + string and cpos the cursor position in the new string + """ return list() + def relevant_part(self, original, pos, sep=' '): + """calculates the subword in a sep-splitted list of original + that pos is in""" + start = original.rfind(sep, 0, pos) + 1 + end = original.find(sep, pos - 1) + if end == -1: + end = len(original) + return original[start:end], start, end, pos - start + class QueryCompleter(Completer): """completion for a notmuch query string""" - # TODO: boolean connectors and braces? - def __init__(self, dbman): + def __init__(self, dbman, accountman): self.dbman = dbman - self._contactscompleter = ContactsCompleter() + abooks = accountman.get_addressbooks() + self._contactscompleter = ContactsCompleter(abooks, addressesonly=True) self._tagscompleter = TagsCompleter(dbman) self.keywords = ['tag', 'from', 'to', 'subject', 'attachment', 'is', 'id', 'thread', 'folder'] - def complete(self, original): - prefix = original.split(' ')[-1] - m = re.search('(tag|is|to):(\w*)', prefix) + def complete(self, original, pos): + mypart, start, end, mypos = self.relevant_part(original, pos) + myprefix = mypart[:mypos] + m = re.search('(tag|is|to):(\w*)', myprefix) if m: cmd, params = m.groups() + cmdlen = len(cmd) + 1 # length of the keyword part incld colon if cmd == 'to': - return self._contactscompleter.complete(params) + localres = self._contactscompleter.complete(mypart[cmdlen:], + mypos - cmdlen) else: - return self._tagscompleter.complete(params, last=True) + localres = self._tagscompleter.complete(mypart[cmdlen:], + mypos - cmdlen) + resultlist = [] + for ltxt, lpos in localres: + newtext = original[:start] + cmd + ':' + ltxt + original[end:] + newpos = start + len(cmd) + 1 + lpos + resultlist.append((newtext, newpos)) + return resultlist else: - plen = len(prefix) - matched = filter(lambda t: t.startswith(prefix), self.keywords) - return [t[plen:] + ':' for t in matched] + matched = filter(lambda t: t.startswith(myprefix), self.keywords) + resultlist = [] + for keyword in matched: + newprefix = original[:start] + keyword + ':' + resultlist.append((newprefix + original[end:], len(newprefix))) + return resultlist class TagsCompleter(Completer): @@ -61,26 +92,47 @@ class TagsCompleter(Completer): def __init__(self, dbman): self.dbman = dbman - def complete(self, original, last=False): - otags = original.split(',') - prefix = otags[-1] + def complete(self, original, pos, single_tag=True): tags = self.dbman.get_all_tags() - if len(otags) > 1 and last: - return [] + if single_tag: + prefix = original[:pos] + matching = [t for t in tags if t.startswith(prefix)] + return [(t, len(t)) for t in matching] else: - matching = [t[len(prefix):] for t in tags if t.startswith(prefix)] - if last: - return matching - else: - return [t + ',' for t in matching] + mypart, start, end, mypos = self.relevant_part(original, pos, + sep=',') + prefix = mypart[:mypos] + res = [] + for tag in tags: + if tag.startswith(prefix): + newprefix = original[:start] + tag + if not original[end:].startswith(','): + newprefix += ',' + res.append((newprefix + original[end:], len(newprefix))) + return res class ContactsCompleter(Completer): """completes contacts""" + def __init__(self, abooks, addressesonly=False): + self.abooks = abooks + self.addressesonly = addressesonly - def complete(self, prefix): - # TODO - return [] + def complete(self, original, pos): + if not self.abooks: + return [] + prefix = original[:pos] + res = [] + for abook in self.abooks: + res = res + abook.lookup(prefix) + if self.addressesonly: + returnlist = [(email, len(email)) for (name, email) in res] + else: + returnlist = [] + for name, email in res: + newtext = "%s <%s>" % (name, email) + returnlist.append((newtext, len(newtext))) + return returnlist class AccountCompleter(Completer): @@ -89,9 +141,10 @@ class AccountCompleter(Completer): def __init__(self, accountman): self.accountman = accountman - def complete(self, prefix): + def complete(self, original, pos): valids = self.accountman.get_main_addresses() - return [a[len(prefix):] for a in valids if a.startswith(prefix)] + prefix = original[:pos] + return [(a, len(a)) for a in valids if a.startswith(prefix)] class CommandCompleter(Completer): @@ -101,12 +154,14 @@ class CommandCompleter(Completer): self.dbman = dbman self.mode = mode - def complete(self, original): + def complete(self, original, pos): #TODO refine <tab> should get current querystring + commandprefix = original[:pos] + logging.debug('original="%s" prefix="%s"' % (original, commandprefix)) cmdlist = command.COMMANDS['global'] cmdlist.update(command.COMMANDS[self.mode]) - olen = len(original) - return [t[olen:] + '' for t in cmdlist if t.startswith(original)] + matching = [t for t in cmdlist if t.startswith(commandprefix)] + return [(t, len(t)) for t in matching] class CommandLineCompleter(Completer): @@ -117,41 +172,42 @@ class CommandLineCompleter(Completer): self.accountman = accountman self.mode = mode self._commandcompleter = CommandCompleter(dbman, mode) - self._querycompleter = QueryCompleter(dbman) + self._querycompleter = QueryCompleter(dbman, accountman) self._tagscompleter = TagsCompleter(dbman) - self._contactscompleter = ContactsCompleter() + abooks = accountman.get_addressbooks() + self._contactscompleter = ContactsCompleter(abooks, addressesonly=True) self._pathcompleter = PathCompleter() - def complete(self, prefix): - words = prefix.split(' ', 1) - if len(words) <= 1: # we complete commands - return self._commandcompleter.complete(prefix) + def complete(self, line, pos): + words = line.split(' ', 1) + + res = [] + if pos <= len(words[0]): # we complete commands + for cmd, cpos in self._commandcompleter.complete(line, pos): + newtext = ('%s %s' % (cmd, ' '.join(words[1:]))) + res.append((newtext, cpos + 1)) else: cmd, params = words + localpos = pos - (len(cmd) + 1) if cmd in ['search', 'refine']: - return self._querycompleter.complete(params) + res = self._querycompleter.complete(params, localpos) if cmd == 'retag': - return self._tagscompleter.complete(params) + res = self._tagscompleter.complete(params, localpos, + single_tag=False) if cmd == 'toggletag': - return self._tagscompleter.complete(params, last=True) + res = self._tagscompleter.complete(params, localpos) if cmd in ['to', 'compose']: - return self._contactscompleter.complete(params) + res = self._contactscompleter.complete(params, localpos) if cmd in ['attach', 'edit', 'save']: - return self._pathcompleter.complete(params) - else: - return [] + res = self._pathcompleter.complete(params, localpos) + res = [('%s %s' % (cmd, t), p + len(cmd) + 1) for (t, p) in res] + return res class PathCompleter(Completer): """completion for paths""" - def complete(self, prefix): - if not prefix: - return ['~/'] - dir = os.path.expanduser(os.path.dirname(prefix)) - fileprefix = os.path.basename(prefix) - res = [] - if os.path.isdir(dir): - for f in os.listdir(dir): - if f.startswith(fileprefix): - res.append(f[len(fileprefix):]) - return res + def complete(self, original, pos): + if not original: + return [('~/', 2)] + prefix = os.path.expanduser(original[:pos]) + return [(f, len(f)) for f in glob.glob(prefix + '*')] @@ -38,7 +38,7 @@ class DatabaseLockedError(DatabaseError): pass -class DBManager: +class DBManager(object): """ keeps track of your index parameters, can create notmuch.Query objects from its Database on demand and implements a bunch of @@ -174,7 +174,7 @@ class DBManager: return db.create_query(querystring) -class Thread: +class Thread(object): def __init__(self, dbman, thread): """ :param dbman: db manager that is used for further lookups diff --git a/data/example.full.rc b/alot/defaults/alot.rc index d31e78a5..1426c5e4 100644 --- a/data/example.full.rc +++ b/alot/defaults/alot.rc @@ -18,16 +18,15 @@ display_content_in_threadline = False # headers that get displayed by default displayed_headers = From,To,Cc,Bcc,Subject - # editor command editor_cmd = /usr/bin/vim -f -c 'set filetype=mail' + -editor_writes_encoding' = UTF-8 +editor_writes_encoding = UTF-8 # timeout in secs after a failed attempt to flush is repeated flush_retry_timeout = 5 # where to look up hooks -hooksfile = ~/.alot.py +hooksfile = ~/.config/alot/hooks.py # time in secs to display status messages notify_timeout = 2 @@ -44,48 +43,57 @@ terminal_cmd = x-terminal-emulator -e # http://docs.python.org/library/datetime.html#strftime-strptime-behavior timestamp_format = '' -#how to print messages: -print_cmd = muttprint +# how to print messages: +# this specifies a shellcommand used pro printing. +# threads/messages are piped to this as plaintext. +# muttprint/a2ps works nicely +print_cmd = '' #initial searchstring when none is given as argument: initial_searchstring = tag:inbox AND NOT tag:killed +#initial searchstring when none is given as argument: +initial_searchstring = tag:inbox AND NOT tag:killed + +# in case more than one account has an addressbook: +# Set this to True to make tabcompletion for recipients during compose only +# look in the abook of the account matching the sender address +complete_matching_abook_only = False [global-maps] -$ = flush -: = prompt -; = bufferlist @ = refresh I = search tag:inbox AND NOT tag:killed L = taglist +shift tab = bprevious U = search tag:unread -\ = prompt search +tab = bnext +\ = 'prompt search ' d = bclose +$ = flush m = compose -o = prompt search +o = 'prompt search ' q = exit -s = shell -shift tab = bprevious -tab = bnext +';' = bufferlist +colon = prompt [bufferlist-maps] x = closefocussed enter = openfocussed [search-maps] +a = toggletag inbox & = toggletag killed +l = retagprompt O = refineprompt -a = toggletag inbox enter = openthread -l = retagprompt | = refineprompt [envelope-maps] -enter = reedit -a = attach -s = prompt subject -t = prompt to +a = prompt attach ~/ y = send +s = 'prompt subject ' +t = 'prompt to ' +enter = reedit [taglist-maps] enter = select @@ -94,184 +102,191 @@ enter = select C = fold --all E = unfold --all H = toggleheaders -P = print --all +P = print --thread +S = save --all a = toggletag inbox -enter = select -f = forward g = groupreply +f = forward p = print +s = save r = reply +enter = select +| = 'prompt pipeto ' [command-aliases] +quit = exit bn = bnext -bp = bprevious clo = close +bp = bprevious ls = bufferlist -quit = exit - [16c-theme] -bufferlist_focus_bg = dark gray bufferlist_focus_fg = white +threadline_mailcount_focus_bg = dark cyan +bufferlist_focus_bg = dark gray +tag_bg = black +message_attachment_fg = light gray +threadline_authors_focus_fg = black,bold +threadline_bg = default +notify_error_fg = white bufferlist_results_even_bg = black +threadline_subject_fg = light gray +message_header_value_bg = dark gray +messagesummary_even_fg = white +threadline_authors_focus_bg = dark cyan +footer_fg = light green +bufferlist_results_odd_fg = light gray +threadline_date_fg = light gray bufferlist_results_even_fg = light gray +message_header_value_fg = light gray +threadline_subject_bg = default +messagesummary_odd_fg = white +message_header_key_fg = white +threadline_tags_focus_bg = dark cyan +tag_focus_fg = white bufferlist_results_odd_bg = black -bufferlist_results_odd_fg = light gray -footer_bg = dark blue -footer_fg = light green +threadline_fg = default +prompt_fg = light gray header_bg = dark blue -header_fg = white -message_attachment_bg = dark gray -message_attachment_fg = light gray -message_attachment_focussed_bg = light green -message_attachment_focussed_fg = light gray -message_body_bg = default +threadline_subject_focus_bg = dark cyan message_body_fg = light gray -message_header_bg = dark gray -message_header_fg = white message_header_key_bg = dark gray -message_header_key_fg = white -message_header_value_bg = dark gray -message_header_value_fg = light gray +threadline_date_bg = default +tag_focus_bg = dark cyan +messagesummary_odd_bg = dark blue +threadline_subject_focus_fg = black +threadline_date_focus_fg = black messagesummary_even_bg = light blue -messagesummary_even_fg = white +message_header_fg = white +threadline_focus_fg = white messagesummary_focus_bg = dark cyan +threadline_date_focus_bg = dark cyan +threadline_content_bg = default +threadline_tags_bg = default +threadline_mailcount_bg = default messagesummary_focus_fg = white -messagesummary_odd_bg = dark blue -messagesummary_odd_fg = white -notify_error_bg = dark red -notify_error_fg = white -notify_normal_bg = default -notify_normal_fg = default +message_body_bg = default +threadline_mailcount_focus_fg = black prompt_bg = black -prompt_fg = light gray -tag_bg = black -tag_fg = brown -tag_focus_bg = dark cyan -tag_focus_fg = white -threadline_authors_bg = default -threadline_authors_fg = dark green -threadline_authors_focus_bg = dark cyan -threadline_authors_focus_fg = black,bold -threadline_bg = default -threadline_content_bg = default +header_fg = white threadline_content_fg = dark gray -threadline_date_bg = default -threadline_date_fg = light gray -threadline_date_focus_bg = dark cyan -threadline_date_focus_fg = black -threadline_fg = default threadline_focus_bg = dark cyan -threadline_focus_fg = white -threadline_mailcount_bg = default threadline_mailcount_fg = light gray -threadline_mailcount_focus_bg = dark cyan -threadline_mailcount_focus_fg = black -threadline_subject_bg = default -threadline_subject_fg = light gray -threadline_subject_focus_bg = dark cyan -threadline_subject_focus_fg = black -threadline_tags_bg = default +threadline_content_focus_bg = dark cyan +tag_fg = brown +notify_normal_fg = default +message_attachment_focussed_bg = light green threadline_tags_fg = brown -threadline_tags_focus_bg = dark cyan +notify_error_bg = dark red +threadline_content_focus_fg = dark gray +footer_bg = dark blue +threadline_authors_bg = default threadline_tags_focus_fg = yellow,bold +message_attachment_bg = dark gray +notify_normal_bg = default +message_attachment_focussed_fg = light gray +threadline_authors_fg = dark green +message_header_bg = dark gray [256c-theme] -bufferlist_focus_bg = g38 bufferlist_focus_fg = #ffa +threadline_mailcount_focus_bg = g58 +bufferlist_focus_bg = g38 +tag_bg = default +message_attachment_fg = light gray +threadline_authors_focus_fg = #8f6 +threadline_bg = default +notify_error_fg = white bufferlist_results_even_bg = g3 +threadline_subject_fg = g58 +message_header_value_bg = dark gray +messagesummary_even_fg = white +threadline_authors_focus_bg = g58 +footer_fg = white +bufferlist_results_odd_fg = default +threadline_date_fg = g58 bufferlist_results_even_fg = default +message_header_value_fg = light gray +threadline_subject_bg = default +messagesummary_odd_fg = white +message_header_key_fg = white +threadline_tags_focus_bg = g58 +tag_focus_fg = #ffa bufferlist_results_odd_bg = default -bufferlist_results_odd_fg = default -footer_bg = #006 -footer_fg = white +threadline_fg = default +prompt_fg = light gray header_bg = dark blue -header_fg = white -message_attachment_bg = dark gray -message_attachment_fg = light gray -message_attachment_focussed_bg = light green -message_attachment_focussed_fg = light gray -message_body_bg = default +threadline_subject_focus_bg = g58 message_body_fg = light gray -message_header_bg = dark gray -message_header_fg = white message_header_key_bg = dark gray -message_header_key_fg = white -message_header_value_bg = dark gray -message_header_value_fg = light gray +threadline_date_bg = default +tag_focus_bg = g58 +messagesummary_odd_bg = #006 +threadline_subject_focus_fg = g89 +threadline_date_focus_fg = g89 messagesummary_even_bg = #068 -messagesummary_even_fg = white +message_header_fg = white +threadline_focus_fg = white messagesummary_focus_bg = g58 +threadline_date_focus_bg = g58 +threadline_content_bg = default +threadline_tags_bg = default +threadline_mailcount_bg = default messagesummary_focus_fg = #ff8 -messagesummary_odd_bg = #006 -messagesummary_odd_fg = white -notify_error_bg = dark red -notify_error_fg = white -notify_normal_bg = default -notify_normal_fg = default +message_body_bg = default +threadline_mailcount_focus_fg = g89 prompt_bg = default -prompt_fg = light gray -tag_bg = default -tag_fg = brown -tag_focus_bg = g58 -tag_focus_fg = #ffa -threadline_authors_bg = default -threadline_authors_fg = #6d6 -threadline_authors_focus_bg = g58 -threadline_authors_focus_fg = #8f6 -threadline_bg = default -threadline_content_bg = default +header_fg = white threadline_content_fg = #866 -threadline_date_bg = default -threadline_date_fg = g58 -threadline_date_focus_bg = g58 -threadline_date_focus_fg = g89 -threadline_fg = default threadline_focus_bg = g58 -threadline_focus_fg = white -threadline_mailcount_bg = default threadline_mailcount_fg = light gray -threadline_mailcount_focus_bg = g58 -threadline_mailcount_focus_fg = g89 -threadline_subject_bg = default -threadline_subject_fg = g58 -threadline_subject_focus_bg = g58 -threadline_subject_focus_fg = g89 -threadline_tags_bg = default +threadline_content_focus_bg = g58 +tag_fg = brown +notify_normal_fg = default +message_attachment_focussed_bg = light green threadline_tags_fg = #a86 -threadline_tags_focus_bg = g58 +notify_error_bg = dark red +threadline_content_focus_fg = #866 +footer_bg = #006 +threadline_authors_bg = default threadline_tags_focus_fg = #ff8 +message_attachment_bg = dark gray +notify_normal_bg = default +message_attachment_focussed_fg = light gray +threadline_authors_fg = #6d6 +message_header_bg = dark gray [1c-theme] -bufferlist_focus = standout -bufferlist_results_even = default -bufferlist_results_odd = default -footer = standout -header = standout -message_attachment = default -message_attachment_focussed = underline message_body = default message_header = default -message_header_key = default -message_header_value = default -messagesummary_even = -messagesummary_focus = standout -messagesummary_odd = -notify_error = standout +prompt = +message_attachment = default notify_normal = default -prompt = -tag = default +messagesummary_odd = +threadline_content = default +threadline_subject_focus = standout +bufferlist_results_odd = default +messagesummary_focus = standout +message_attachment_focussed = underline tag_focus = standout -threadline = default +header = standout +tag = default +threadline_content_focus = standout +message_header_value = default +threadline_mailcount_focus = standout +threadline_date_focus = standout +message_header_key = default +threadline_subject = default threadline_authors = default,underline +bufferlist_results_even = default threadline_authors_focus = standout -threadline_content = default +footer = standout +notify_error = standout +messagesummary_even = +threadline_tags_focus = standout +threadline = default threadline_date = default -threadline_date_focus = standout -threadline_focus = standout threadline_mailcount = default -threadline_mailcount_focus = standout -threadline_subject = default -threadline_subject_focus = standout +threadline_focus = standout threadline_tags = bold -threadline_tags_focus = standout +bufferlist_focus = standout diff --git a/alot/defaults/notmuch.rc b/alot/defaults/notmuch.rc new file mode 100644 index 00000000..f03fcda1 --- /dev/null +++ b/alot/defaults/notmuch.rc @@ -0,0 +1,3 @@ +[maildir] +synchronize_flags = False + diff --git a/alot/helper.py b/alot/helper.py index eb0d4978..91da0811 100644 --- a/alot/helper.py +++ b/alot/helper.py @@ -51,7 +51,7 @@ def pretty_datetime(d): def cmd_output(command_line): - args = shlex.split(command_line) + args = shlex.split(command_line.encode('ascii', errors='ignore')) try: output = subprocess.check_output(args) except subprocess.CalledProcessError: @@ -61,6 +61,25 @@ def cmd_output(command_line): return output +def pipe_to_command(cmd, stdin): + # no unicode in shlex on 2.x + args = shlex.split(cmd.encode('ascii')) + try: + proc = subprocess.Popen(args, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + out, err = proc.communicate(stdin) + except OSError, e: + return '', str(e) + if proc.poll(): # returncode is not 0 + e = 'return value != 0' + if err.strip(): + e = e + ': %s' % err + return '', e + else: + return out, err + + def attach(path, mail, filename=None): ctype, encoding = mimetypes.guess_type(path) if ctype is None or encoding is not None: @@ -94,3 +113,13 @@ def attach(path, mail, filename=None): part.add_header('Content-Disposition', 'attachment', filename=filename) mail.attach(part) + + +def shell_quote(text): + r''' + >>> print(shell_quote("hello")) + 'hello' + >>> print(shell_quote("hello'there")) + 'hello'"'"'there' + ''' + return "'%s'" % text.replace("'", """'"'"'""") diff --git a/alot/init.py b/alot/init.py index 9972e42f..93c84149 100755 --- a/alot/init.py +++ b/alot/init.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python """ This file is part of alot, a terminal UI to notmuch mail (notmuchmail.org). Copyright (C) 2011 Patrick Totzke <patricktotzke@gmail.com> @@ -16,6 +16,7 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. """ +import sys import argparse import logging import os @@ -30,7 +31,7 @@ from urwid.command_map import command_map def parse_args(): parser = argparse.ArgumentParser() parser.add_argument('-c', dest='configfile', - default='~/.alot.rc', + default=None, help='alot\'s config file') parser.add_argument('-n', dest='notmuchconfigfile', default='~/.notmuch-config', @@ -61,9 +62,25 @@ def main(): # interpret cml arguments args = parse_args() - #read config file - configfilename = os.path.expanduser(args.configfile) - settings.config.read(configfilename) + #locate and read config file + configfiles = [ + os.path.join(os.environ.get('XDG_CONFIG_HOME', + os.path.expanduser('~/.config')), + 'alot', 'config'), + os.path.expanduser('~/.alot.rc'), + ] + if args.configfile: + expanded_path = os.path.expanduser(args.configfile) + if not os.path.exists(expanded_path): + sys.exit('File %s does not exist' % expanded_path) + configfiles.insert(0, expanded_path) + + found_config = False + for configfilename in configfiles: + if os.path.exists(configfilename): + settings.config.read(configfilename) + found_config = True + notmuchfile = os.path.expanduser(args.notmuchconfigfile) settings.notmuchconfig.read(notmuchfile) settings.hooks.setup(settings.config.get('general', 'hooksfile')) @@ -88,7 +105,7 @@ def main(): command_map['esc'] = 'cancel' # get initial searchstring - query = settings.config.get('general','initial_searchstring') + query = settings.config.get('general', 'initial_searchstring') if args.query != '': query = args.query diff --git a/alot/message.py b/alot/message.py index f3128eba..e8cf539c 100644 --- a/alot/message.py +++ b/alot/message.py @@ -29,7 +29,7 @@ from settings import get_mime_handler from settings import config -class Message: +class Message(object): def __init__(self, dbman, msg, thread=None): """ :param dbman: db manager that is used for further lookups @@ -279,7 +279,7 @@ def encode_header(key, value): return value -class Attachment: +class Attachment(object): """represents a single mail attachment""" def __init__(self, emailpart): @@ -296,7 +296,10 @@ class Attachment: def get_filename(self): """return the filename, extracted from content-disposition header""" - return self.part.get_filename() + extracted_name = self.part.get_filename() + if extracted_name: + return os.path.basename(extracted_name) + return None def get_content_type(self): """mime type of the attachment""" @@ -316,11 +319,16 @@ class Attachment: def save(self, path): """save the attachment to disk. Uses self.get_filename in case path is a directory""" - if self.get_filename() and os.path.isdir(path): - path = os.path.join(path, self.get_filename()) - FILE = open(path, "w") + filename = self.get_filename() + path = os.path.expanduser(path) + if os.path.isdir(path): + if filename: + basename = os.path.basename(filename) + FILE = open(os.path.join(path, basename), "w") + else: + FILE = tempfile.NamedTemporaryFile(delete=False, dir=path) else: - FILE = tempfile.NamedTemporaryFile(delete=False) + FILE = open(path, "w") # this throws IOErrors for invalid path FILE.write(self.part.get_payload(decode=True)) FILE.close() return FILE.name diff --git a/alot/settings.py b/alot/settings.py index 4ddf4fce..5afd4463 100644 --- a/alot/settings.py +++ b/alot/settings.py @@ -18,302 +18,31 @@ Copyright (C) 2011 Patrick Totzke <patricktotzke@gmail.com> """ import imp import os +import ast import mailcap import codecs from ConfigParser import SafeConfigParser -DEFAULTS = { - 'general': { - 'colourmode': '256', - 'editor_cmd': "/usr/bin/vim -f -c 'set filetype=mail' +", - 'editor_writes_encoding': 'UTF-8', - 'terminal_cmd': 'x-terminal-emulator -e', - 'spawn_editor': 'False', - 'displayed_headers': 'From,To,Cc,Bcc,Subject', - 'display_content_in_threadline': 'False', - 'authors_maxlength': '30', - 'ask_subject': 'True', - 'notify_timeout': '2', - 'show_statusbar': 'True', - 'flush_retry_timeout': '5', - 'hooksfile': '~/.alot.py', - 'bug_on_exit': 'False', - 'timestamp_format': '', - 'print_cmd': 'muttprint', - 'initial_searchstring': 'tag:inbox AND NOT tag:killed', - }, - '16c-theme': { - 'bufferlist_focus_bg': 'dark gray', - 'bufferlist_focus_fg': 'white', - 'bufferlist_results_even_bg': 'black', - 'bufferlist_results_even_fg': 'light gray', - 'bufferlist_results_odd_bg': 'black', - 'bufferlist_results_odd_fg': 'light gray', - 'footer_bg': 'dark blue', - 'footer_fg': 'light green', - 'header_bg': 'dark blue', - 'header_fg': 'white', - 'message_attachment_bg': 'dark gray', - 'message_attachment_fg': 'light gray', - 'message_attachment_focussed_bg': 'light green', - 'message_attachment_focussed_fg': 'light gray', - 'message_body_bg': 'default', - 'message_body_fg': 'light gray', - 'message_header_bg': 'dark gray', - 'message_header_fg': 'white', - 'message_header_key_bg': 'dark gray', - 'message_header_key_fg': 'white', - 'message_header_value_bg': 'dark gray', - 'message_header_value_fg': 'light gray', - 'messagesummary_even_bg': 'light blue', - 'messagesummary_even_fg': 'white', - 'messagesummary_focus_bg': 'dark cyan', - 'messagesummary_focus_fg': 'white', - 'messagesummary_odd_bg': 'dark blue', - 'messagesummary_odd_fg': 'white', - 'notify_error_bg': 'dark red', - 'notify_error_fg': 'white', - 'notify_normal_bg': 'default', - 'notify_normal_fg': 'default', - 'prompt_bg': 'black', - 'prompt_fg': 'light gray', - 'tag_focus_bg': 'dark cyan', - 'tag_focus_fg': 'white', - 'tag_bg': 'black', - 'tag_fg': 'brown', - 'threadline_authors_bg': 'default', - 'threadline_authors_fg': 'dark green', - 'threadline_authors_focus_bg': 'dark cyan', - 'threadline_authors_focus_fg': 'black,bold', - 'threadline_bg': 'default', - 'threadline_content_bg': 'default', - 'threadline_content_fg': 'dark gray', - 'threadline_content_focus_bg': 'dark cyan', - 'threadline_content_focus_fg': 'dark gray', - 'threadline_date_bg': 'default', - 'threadline_date_fg': 'light gray', - 'threadline_date_focus_bg': 'dark cyan', - 'threadline_date_focus_fg': 'black', - 'threadline_fg': 'default', - 'threadline_focus_bg': 'dark cyan', - 'threadline_focus_fg': 'white', - 'threadline_mailcount_bg': 'default', - 'threadline_mailcount_fg': 'light gray', - 'threadline_mailcount_focus_bg': 'dark cyan', - 'threadline_mailcount_focus_fg': 'black', - 'threadline_subject_bg': 'default', - 'threadline_subject_fg': 'light gray', - 'threadline_subject_focus_bg': 'dark cyan', - 'threadline_subject_focus_fg': 'black', - 'threadline_tags_bg': 'default', - 'threadline_tags_fg': 'brown', - 'threadline_tags_focus_bg': 'dark cyan', - 'threadline_tags_focus_fg': 'yellow,bold', - }, - '1c-theme': { - 'bufferlist_focus': 'standout', - 'bufferlist_results_even': 'default', - 'bufferlist_results_odd': 'default', - 'footer': 'standout', - 'header': 'standout', - 'message_attachment': 'default', - 'message_attachment_focussed': 'underline', - 'message_body': 'default', - 'message_header': 'default', - 'message_header_key': 'default', - 'message_header_value': 'default', - 'messagesummary_even': '', - 'messagesummary_focus': 'standout', - 'messagesummary_odd': '', - 'notify_error': 'standout', - 'notify_normal': 'default', - 'prompt': '', - 'tag_focus': 'standout', - 'tag': 'default', - 'threadline': 'default', - 'threadline_authors': 'default,underline', - 'threadline_authors_focus': 'standout', - 'threadline_content': 'default', - 'threadline_content_focus': 'standout', - 'threadline_date': 'default', - 'threadline_date_focus': 'standout', - 'threadline_focus': 'standout', - 'threadline_mailcount': 'default', - 'threadline_mailcount_focus': 'standout', - 'threadline_subject': 'default', - 'threadline_subject_focus': 'standout', - 'threadline_tags': 'bold', - 'threadline_tags_focus': 'standout', - }, - '256c-theme': { - 'bufferlist_focus_bg': 'g38', - 'bufferlist_focus_fg': '#ffa', - 'bufferlist_results_even_bg': 'g3', - 'bufferlist_results_even_fg': 'default', - 'bufferlist_results_odd_bg': 'default', - 'bufferlist_results_odd_fg': 'default', - 'footer_bg': '#006', - 'footer_fg': 'white', - 'header_bg': 'dark blue', - 'header_fg': 'white', - 'message_attachment_bg': 'dark gray', - 'message_attachment_fg': 'light gray', - 'message_attachment_focussed_bg': 'light green', - 'message_attachment_focussed_fg': 'light gray', - 'message_body_bg': 'default', - 'message_body_fg': 'light gray', - 'message_header_bg': 'dark gray', - 'message_header_fg': 'white', - 'message_header_key_bg': 'dark gray', - 'message_header_key_fg': 'white', - 'message_header_value_bg': 'dark gray', - 'message_header_value_fg': 'light gray', - 'messagesummary_even_bg': '#068', - 'messagesummary_even_fg': 'white', - 'messagesummary_focus_bg': 'g58', - 'messagesummary_focus_fg': '#ff8', - 'messagesummary_odd_bg': '#006', - 'messagesummary_odd_fg': 'white', - 'notify_error_bg': 'dark red', - 'notify_error_fg': 'white', - 'notify_normal_bg': 'default', - 'notify_normal_fg': 'default', - 'prompt_bg': 'default', - 'prompt_fg': 'light gray', - 'tag_focus_bg': 'g58', - 'tag_focus_fg': '#ffa', - 'tag_bg': 'default', - 'tag_fg': 'brown', - 'threadline_authors_bg': 'default', - 'threadline_authors_fg': '#6d6', - 'threadline_authors_focus_bg': 'g58', - 'threadline_authors_focus_fg': '#8f6', - 'threadline_bg': 'default', - 'threadline_content_bg': 'default', - 'threadline_content_fg': '#866', - 'threadline_content_focus_bg': 'g58', - 'threadline_content_focus_fg': '#866', - 'threadline_date_bg': 'default', - 'threadline_date_fg': 'g58', - 'threadline_date_focus_bg': 'g58', - 'threadline_date_focus_fg': 'g89', - 'threadline_fg': 'default', - 'threadline_focus_bg': 'g58', - 'threadline_focus_fg': 'white', - 'threadline_mailcount_bg': 'default', - 'threadline_mailcount_fg': 'light gray', - 'threadline_mailcount_focus_bg': 'g58', - 'threadline_mailcount_focus_fg': 'g89', - 'threadline_subject_bg': 'default', - 'threadline_subject_fg': 'g58', - 'threadline_subject_focus_bg': 'g58', - 'threadline_subject_focus_fg': 'g89', - 'threadline_tags_bg': 'default', - 'threadline_tags_fg': '#a86', - 'threadline_tags_focus_bg': 'g58', - 'threadline_tags_focus_fg': '#ff8', - }, - 'global-maps': { - '$': 'flush', - ':': 'prompt', - ';': 'bufferlist', - '@': 'refresh', - '@': 'refresh', - 'I': 'search tag:inbox AND NOT tag:killed', - 'L': 'taglist', - 'U': 'search tag:unread', - '\\': 'prompt search ', - 'm': 'compose', - 'o': 'prompt search ', - 'q': 'exit', - 'shift tab': 'bprevious', - 'tab': 'bnext', - 'd': 'bclose', - }, - 'search-maps': { - '&': 'toggletag killed', - 'O': 'refineprompt', - 'a': 'toggletag inbox', - 'enter': 'openthread', - 'l': 'retagprompt', - '|': 'refineprompt', - }, - 'thread-maps': { - 'C': 'fold --all', - 'E': 'unfold --all', - 'H': 'toggleheaders', - 'P': 'print --all', - 'a': 'toggletag inbox', - 'enter': 'select', - 'f': 'forward', - 'g': 'groupreply', - 'p': 'print', - 'r': 'reply', - }, - 'taglist-maps': { - 'enter': 'select', - }, - 'envelope-maps': { - 'a': 'prompt attach ~/', - 'y': 'send', - 'enter': 'reedit', - 't': 'prompt to ', - 's': 'prompt subject ', - }, - 'bufferlist-maps': { - 'x': 'closefocussed', - 'enter': 'openfocussed', - }, - 'command-aliases': { - 'clo': 'close', - 'bn': 'bnext', - 'bp': 'bprevious', - 'ls': 'bufferlist', - 'quit': 'exit', - } -} - -NOTMUCH_DEFAULTS = { - 'maildir': { - 'synchronize_flags': 'False', - }, -} - - -class DefaultsConfigParser(SafeConfigParser): - def __init__(self, defaults): - self.defaults = defaults +class FallbackConfigParser(SafeConfigParser): + def __init__(self): SafeConfigParser.__init__(self) self.optionxform = lambda x: x - for sec in defaults.keys(): - self.add_section(sec) def get(self, section, option, fallback=None, *args, **kwargs): if SafeConfigParser.has_option(self, section, option): return SafeConfigParser.get(self, section, option, *args, **kwargs) - elif section in self.defaults: - if option in self.defaults[section]: - return self.defaults[section][option] return fallback - def has_option(self, section, option, *args, **kwargs): - if SafeConfigParser.has_option(self, section, option): - return True - elif section in self.defaults: - if option in self.defaults[section]: - return True - return False - def getstringlist(self, section, option, **kwargs): value = self.get(section, option, **kwargs) return [s.strip() for s in value.split(',')] -class AlotConfigParser(DefaultsConfigParser): - def __init__(self, defaults): - DefaultsConfigParser.__init__(self, defaults) +class AlotConfigParser(FallbackConfigParser): + def __init__(self): + FallbackConfigParser.__init__(self) self.hooks = None def read(self, file): @@ -329,10 +58,28 @@ class AlotConfigParser(DefaultsConfigParser): except: pass + # fix quoted keys / values + for section in self.sections(): + for key, value in self.items(section): + if value and value[0] in "\"'": + value = ast.literal_eval(value) + + transformed_key = False + if key[0] in "\"'": + transformed_key = ast.literal_eval(key) + elif key == 'colon': + transformed_key = ':' + + if transformed_key: + self.remove_option(section, key) + self.set(section, transformed_key, value) + else: + self.set(section, key, value) + def get_palette(self): mode = self.getint('general', 'colourmode') ms = "%dc-theme" % mode - names = self.options(ms) + DEFAULTS[ms].keys() + names = self.options(ms) if mode > 2: names = set([s[:-3] for s in names]) p = list() @@ -384,7 +131,7 @@ class AlotConfigParser(DefaultsConfigParser): return cmdline -class HookManager: +class HookManager(object): def setup(self, hooksfile): hf = os.path.expanduser(hooksfile) if os.path.isfile(hf): @@ -399,14 +146,15 @@ class HookManager: if self.module: if key in self.module.__dict__: return self.module.__dict__[key] - - def f(*args, **kwargs): - msg = 'called undefined hook: %s with arguments' - return f + return None -config = AlotConfigParser(DEFAULTS) -notmuchconfig = DefaultsConfigParser(NOTMUCH_DEFAULTS) +config = AlotConfigParser() +config.read(os.path.join(os.path.dirname(__file__), 'defaults', 'alot.rc')) +notmuchconfig = FallbackConfigParser() +notmuchconfig.read(os.path.join(os.path.dirname(__file__), + 'defaults', + 'notmuch.rc')) hooks = HookManager() mailcaps = mailcap.getcaps() @@ -19,18 +19,19 @@ Copyright (C) 2011 Patrick Totzke <patricktotzke@gmail.com> import urwid import os from urwid.command_map import command_map +from twisted.internet import defer from settings import config from buffer import BufferlistBuffer from command import commandfactory from command import interpret_commandline -from widgets import CompleteEdit +import widgets from completion import CommandLineCompleter class MainWidget(urwid.Frame): def __init__(self, ui, *args, **kwargs): - urwid.Frame.__init__(self, urwid.SolidFill(' '), *args, **kwargs) + urwid.Frame.__init__(self, urwid.SolidFill(), *args, **kwargs) self.ui = ui def keypress(self, size, key): @@ -44,7 +45,7 @@ class MainWidget(urwid.Frame): urwid.Frame.keypress(self, size, key) -class UI: +class UI(object): buffers = [] current_buffer = None @@ -61,6 +62,7 @@ class UI: self.mainloop = urwid.MainLoop(self.mainframe, config.get_palette(), handle_mouse=False, + event_loop=urwid.TwistedEventLoop(), unhandled_input=self.keypress) self.mainloop.screen.set_terminal_properties(colors=colourmode) @@ -91,59 +93,41 @@ class UI: :type tab: int :param history: history to be used for up/down keys :type history: list of str + :returns: a `twisted.defer.Deferred` """ - self.logger.info('open prompt') - history = list(history) # make a local copy - historypos = None + d = defer.Deferred() # create return deferred + main = self.mainloop.widget # save main widget + + def select_or_cancel(text): + self.mainloop.widget = main # restore main screen + d.callback(text) + + #set up widgets leftpart = urwid.Text(prefix, align='left') - if completer: - editpart = CompleteEdit(completer, edit_text=text) - for i in range(tab): - editpart.keypress((0,), 'tab') - else: - editpart = urwid.Edit(edit_text=text) + editpart = widgets.CompleteEdit(completer, on_exit=select_or_cancel, + edit_text=text, history=history) + + for i in range(tab): # hit some tabs + editpart.keypress((0,), 'tab') + + # build promptwidget both = urwid.Columns( [ ('fixed', len(prefix), leftpart), ('weight', 1, editpart), ]) prompt_widget = urwid.AttrMap(both, 'prompt', 'prompt') - footer = self.mainframe.get_footer() - self.mainframe.set_footer(prompt_widget) - self.mainframe.set_focus('footer') - self.mainloop.draw_screen() - while True: - keys = None - while not keys: - keys = self.mainloop.screen.get_input() - for key in keys: - self.logger.debug('prompt got key: %s' % key) - if command_map[key] == 'select': - self.mainframe.set_footer(footer) - self.mainframe.set_focus('body') - return editpart.get_edit_text() - elif command_map[key] == 'cancel': - self.mainframe.set_footer(footer) - self.mainframe.set_focus('body') - return None - elif key in ['up', 'down']: - if history: - if historypos == None: - history.append(editpart.get_edit_text()) - historypos = len(history) - 1 - if key == 'cursor up': - historypos = (historypos - 1) % len(history) - else: - historypos = (historypos + 1) % len(history) - editpart.set_edit_text(history[historypos]) - self.mainloop.draw_screen() - - else: - size = (20,) # don't know why they want a size here - editpart.keypress(size, key) - self.mainloop.draw_screen() + # put promptwidget as overlay on main widget + overlay = urwid.Overlay(both, main, + ('fixed left', 0), + ('fixed right', 0), + ('fixed bottom', 1), + None) + self.mainloop.widget = overlay + return d # return deferred + @defer.inlineCallbacks def commandprompt(self, startstring): """prompt for a commandline and interpret/apply it upon enter @@ -152,15 +136,15 @@ class UI: """ self.logger.info('open command shell') mode = self.current_buffer.typename - cmdline = self.prompt(prefix=':', + cmdline = yield self.prompt(prefix=':', text=startstring, completer=CommandLineCompleter(self.dbman, self.accountman, mode), history=self.commandprompthistory, ) - if cmdline: - self.interpret_commandline(cmdline) + self.logger.debug('CMDLINE: %s' % cmdline) + self.interpret_commandline(cmdline) def interpret_commandline(self, cmdline): """interpret and apply a commandstring @@ -168,13 +152,14 @@ class UI: :param cmdline: command string to apply :type cmdline: str """ - mode = self.current_buffer.typename - self.commandprompthistory.append(cmdline) - cmd = interpret_commandline(cmdline, mode) - if cmd: - self.apply_command(cmd) - else: - self.notify('invalid command') + if cmdline: + mode = self.current_buffer.typename + self.commandprompthistory.append(cmdline) + cmd = interpret_commandline(cmdline, mode) + if cmd: + self.apply_command(cmd) + else: + self.notify('invalid command') def buffer_open(self, b): """ @@ -251,42 +236,57 @@ class UI: self.notificationbar = None self.update() - def choice(self, message, choices={'yes': ['y'], 'no': ['n']}): + def choice(self, message, choices={'y': 'yes', 'n': 'no'}, + select=None, cancel=None, msg_position='above'): """prompt user to make a choice :param message: string to display before list of choices :type message: unicode :param choices: dict of possible choices - :type choices: str->list of keys + :type choices: keymap->choice (both str) + :param select: choice to return if enter/return is hit. + Ignored if set to None. + :type select: str + :param cancel: choice to return if escape is hit. + Ignored if set to None. + :type cancel: str + :returns: a `twisted.defer.Deferred` """ - def build_line(msg, prio): - cols = urwid.Columns([urwid.Text(msg)]) - return urwid.AttrMap(cols, 'notify_' + prio) - - cstrings = ['(%s):%s' % ('/'.join(v), k) for k, v in choices.items()] - line = ', '.join(cstrings) - msgs = [build_line(message + ' ' + line, 'normal')] - - footer = self.mainframe.get_footer() - if not self.notificationbar: - self.notificationbar = urwid.Pile(msgs) - else: - newpile = self.notificationbar.widget_list + msgs - self.notificationbar = urwid.Pile(newpile) - self.update() + assert select in choices.values() + [None] + assert cancel in choices.values() + [None] + assert msg_position in ['left', 'above'] + + d = defer.Deferred() # create return deferred + main = self.mainloop.widget # save main widget + + def select_or_cancel(text): + self.mainloop.widget = main # restore main screen + d.callback(text) + + #set up widgets + msgpart = urwid.Text(message) + choicespart = widgets.ChoiceWidget(choices, callback=select_or_cancel, + select=select, cancel=cancel) + + # build widget + if msg_position == 'left': + both = urwid.Columns( + [ + ('fixed', len(message), msgpart), + ('weight', 1, choicespart), + ], dividechars=1) + else: # above + both = urwid.Pile([msgpart, choicespart]) + prompt_widget = urwid.AttrMap(both, 'prompt', 'prompt') - self.mainloop.draw_screen() - while True: - result = self.mainloop.screen.get_input() - self.logger.info('got: %s ' % result) - if not result: - self.clear_notify(msgs) - self.mainloop.screen.get_input() - return None - for k, v in choices.items(): - if result[0] in v: - self.clear_notify(msgs) - return k + # put promptwidget as overlay on main widget + overlay = urwid.Overlay(both, main, + ('fixed left', 0), + ('fixed right', 0), + ('fixed bottom', 1), + None) + self.mainloop.widget = overlay + return d # return deferred def notify(self, message, priority='normal', timeout=0, block=False): """notify popup @@ -377,7 +377,9 @@ class UI: if cmd.prehook: self.logger.debug('calling pre-hook') try: - cmd.prehook(self, self.dbman, self.accountman, config) + cmd.prehook(ui=self, dbm=self.dbman, aman=self.accountman, + log=self.logger, config=config) + except: self.logger.exception('prehook failed') self.logger.debug('apply command: %s' % cmd) @@ -385,6 +387,7 @@ class UI: if cmd.posthook: self.logger.debug('calling post-hook') try: - cmd.posthook(self, self.dbman, self.accountman, config) + cmd.posthook(ui=self, dbm=self.dbman, aman=self.accountman, + log=self.logger, config=config) except: self.logger.exception('posthook failed') diff --git a/alot/widgets.py b/alot/widgets.py index 2b811146..1645aff5 100644 --- a/alot/widgets.py +++ b/alot/widgets.py @@ -63,7 +63,7 @@ class ThreadlineWidget(urwid.AttrMap): authors = self.thread.get_authors() or '(None)' maxlength = config.getint('general', 'authors_maxlength') - authorsstring = shorten(authors, maxlength).strip() + authorsstring = shorten(authors, maxlength) self.authors_w = urwid.AttrMap(urwid.Text(authorsstring), 'threadline_authors') cols.append(('fixed', len(authorsstring), self.authors_w)) @@ -166,43 +166,91 @@ class TagWidget(urwid.AttrMap): self.set_attr_map({None: config.get_tagattr(self.tag)}) +class ChoiceWidget(urwid.Text): + def __init__(self, choices, callback, cancel=None, select=None): + self.choices = choices + self.callback = callback + self.cancel = cancel + self.select = select + + items = [] + for k, v in choices.items(): + if v == select and select != None: + items.append('[%s]:%s' % (k, v)) + else: + items.append('(%s):%s' % (k, v)) + urwid.Text.__init__(self, ' '.join(items)) + + def selectable(self): + return True + + def keypress(self, size, key): + cmd = command_map[key] + if cmd == 'select' and self.select != None: + self.callback(self.select) + elif cmd == 'cancel' and self.cancel != None: + self.callback(self.cancel) + elif key in self.choices: + self.callback(self.choices[key]) + else: + return key + + class CompleteEdit(urwid.Edit): - def __init__(self, completer, edit_text=u'', **kwargs): + def __init__(self, completer, on_exit, edit_text=u'', + history=None, **kwargs): self.completer = completer + self.on_exit = on_exit + self.history = list(history) # we temporarily add stuff here + self.historypos = None + if not isinstance(edit_text, unicode): edit_text = unicode(edit_text, errors='replace') self.start_completion_pos = len(edit_text) - self.completion_results = None + self.completions = None urwid.Edit.__init__(self, edit_text=edit_text, **kwargs) def keypress(self, size, key): cmd = command_map[key] - if cmd in ['next selectable', 'prev selectable']: - pos = self.start_completion_pos - original = self.edit_text[:pos] - if not self.completion_results: # not in completion mode - self.completion_results = [''] + \ - self.completer.complete(original) + # if we tabcomplete + if cmd in ['next selectable', 'prev selectable'] and self.completer: + # if not already in completion mode + if not self.completions: + self.completions = [(self.edit_text, self.edit_pos)] + \ + self.completer.complete(self.edit_text, self.edit_pos) self.focus_in_clist = 1 - else: + else: # otherwise tab through results if cmd == 'next selectable': self.focus_in_clist += 1 else: self.focus_in_clist -= 1 - if len(self.completion_results) > 1: - suffix = self.completion_results[self.focus_in_clist % - len(self.completion_results)] - self.set_edit_text(original + suffix) - self.edit_pos += len(suffix) + if len(self.completions) > 1: + ctext, cpos = self.completions[self.focus_in_clist % + len(self.completions)] + self.set_edit_text(ctext) + self.set_edit_pos(cpos) else: - self.set_edit_text(original + ' ') self.edit_pos += 1 - self.start_completion_pos = self.edit_pos - self.completion_results = None + if self.edit_pos >= len(self.edit_text): + self.edit_text += ' ' + self.completions = None + elif key in ['up', 'down']: + if self.history: + if self.historypos == None: + self.history.append(self.edit_text) + self.historypos = len(self.history) - 1 + if key == 'cursor up': + self.historypos = (self.historypos + 1) % len(self.history) + else: + self.historypos = (self.historypos - 1) % len(self.history) + self.set_edit_text(self.history[self.historypos]) + elif cmd == 'select': + self.on_exit(self.edit_text) + elif cmd == 'cancel': + self.on_exit(None) else: result = urwid.Edit.keypress(self, size, key) - self.start_completion_pos = self.edit_pos - self.completion_results = None + self.completions = None return result diff --git a/data/example.rc b/data/example.rc index 699bfd53..897ab4be 100644 --- a/data/example.rc +++ b/data/example.rc @@ -2,7 +2,6 @@ colourmode = 256 ; number of colours your terminal supports hooksfile = hooks.py editor_cmd = /usr/bin/vim -f -c 'set filetype=mail' -pager_cmd = /usr/bin/view -f -c 'set filetype=mail' terminal_cmd = /usr/bin/urxvt -T notmuch -e spawn_editor = False authors_maxlength = 30 @@ -10,6 +9,7 @@ displayed_headers = From,To,Cc,Bcc,Subject ask_subject = True notify_timeout = 1 # how long (in seconds) notifications are shown flush_retry_timeout = 5 # timeout in secs after a failed attempt to flush is repeated +print_cmd = muttprint [account gmail] realname = john doe @@ -21,6 +21,7 @@ signature_filename = sig.txt # filename in attachment part sendmail_command = msmtp --account=gmail -t sent_box = maildir:///home/john/mail/gmail/Sent # we accept mbox,maildir,mh,babyl,mmdf here draft_box = maildir:///home/john/mail/gmail/Drafts # we accept mbox,maildir,mh,babyl,mmdf here +abook_command = abook --mutt-query [search-maps] t = toggletag todo @@ -10,6 +10,7 @@ setup(name='alot', author_email=alot.__author_email__, url=alot.__url__, packages=['alot'], + package_data={'alot': ['defaults/alot.rc', 'defaults/notmuch.rc']}, scripts=['bin/alot'], license=alot.__copyright__, requires=[ |