diff options
-rw-r--r-- | alot/__init__.py | 1 | ||||
-rw-r--r-- | alot/buffers.py (renamed from alot/buffer.py) | 4 | ||||
-rw-r--r-- | alot/command.py | 1314 | ||||
-rw-r--r-- | alot/commands/__init__.py | 142 | ||||
-rw-r--r-- | alot/commands/bufferlist.py | 1 | ||||
-rw-r--r-- | alot/commands/envelope.py | 225 | ||||
-rw-r--r-- | alot/commands/globals.py | 462 | ||||
-rw-r--r-- | alot/commands/search.py | 150 | ||||
-rw-r--r-- | alot/commands/taglist.py | 12 | ||||
-rw-r--r-- | alot/commands/thread.py | 412 | ||||
-rw-r--r-- | alot/completion.py | 6 | ||||
-rw-r--r-- | alot/defaults/alot.rc | 11 | ||||
-rwxr-xr-x | alot/init.py | 21 | ||||
-rw-r--r-- | alot/ui.py | 29 | ||||
-rwxr-xr-x | setup.py | 2 |
15 files changed, 1446 insertions, 1346 deletions
diff --git a/alot/__init__.py b/alot/__init__.py index 4a84dc9b..8d2b1835 100644 --- a/alot/__init__.py +++ b/alot/__init__.py @@ -24,3 +24,4 @@ __author_email__ = "patricktotzke@gmail.com" __description__ = "Command-line MUA using notmuch mail" __url__ = "https://github.com/pazz/alot" __license__ = "Licensed under the GNU GPL v3+." + diff --git a/alot/buffer.py b/alot/buffers.py index b313c231..57fe9ecb 100644 --- a/alot/buffer.py +++ b/alot/buffers.py @@ -22,7 +22,7 @@ from notmuch.globals import NotmuchError import widgets import settings -import command +import commands from walker import IteratorWalker from message import decode_header @@ -252,7 +252,7 @@ class ThreadBuffer(Buffer): mw.fold(visible=True) if 'unread' in msg.get_tags(): msg.remove_tags(['unread']) - self.ui.apply_command(command.FlushCommand()) + self.ui.apply_command(commands.globals.FlushCommand()) class TagListBuffer(Buffer): diff --git a/alot/command.py b/alot/command.py deleted file mode 100644 index 34c1928a..00000000 --- a/alot/command.py +++ /dev/null @@ -1,1314 +0,0 @@ -""" -This file is part of alot. - -Alot is free software: you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation, either version 3 of the License, or (at your -option) any later version. - -Alot is distributed in the hope that it will be useful, but WITHOUT -ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License -for more details. - -You should have received a copy of the GNU General Public License -along with notmuch. If not, see <http://www.gnu.org/licenses/>. - -Copyright (C) 2011 Patrick Totzke <patricktotzke@gmail.com> -""" -import os -import re -import code -import glob -import logging -import threading -import subprocess -import shlex -import email -import tempfile -from email import Charset -from email.header import Header -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -import urwid -from twisted.internet import defer - -import buffer -import settings -import widgets -import completion -import helper -from db import DatabaseROError -from db import DatabaseLockedError -from completion import ContactsCompleter -from completion import AccountCompleter -from message import decode_to_unicode -from message import decode_header -from message import encode_header - - -class Command(object): - """base class for commands""" - def __init__(self, prehook=None, posthook=None): - self.prehook = prehook - self.posthook = posthook - self.undoable = False - self.help = self.__doc__ - - def apply(self, caller): - pass - - -class ExitCommand(Command): - """shuts the MUA down cleanly""" - @defer.inlineCallbacks - def apply(self, ui): - if settings.config.getboolean('general', 'bug_on_exit'): - if (yield ui.choice('realy quit?', select='yes', cancel='no', - msg_position='left')) == 'no': - return - ui.exit() - - -class OpenThreadCommand(Command): - """open a new thread-view buffer""" - def __init__(self, thread=None, **kwargs): - self.thread = thread - Command.__init__(self, **kwargs) - - def apply(self, ui): - if not self.thread: - self.thread = ui.current_buffer.get_selected_thread() - if self.thread: - query = ui.current_buffer.querystring - ui.logger.info('open thread view for %s' % self.thread) - - sb = buffer.ThreadBuffer(ui, self.thread) - ui.buffer_open(sb) - sb.unfold_matching(query) - - -class SearchCommand(Command): - """open a new search buffer""" - def __init__(self, query, **kwargs): - """ - :param query: initial querystring - """ - 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 (yield ui.choice(s, select='yes', cancel='no')) == 'no': - return - open_searches = ui.get_buffers_of_type(buffer.SearchBuffer) - to_be_focused = None - for sb in open_searches: - if sb.querystring == self.query: - to_be_focused = sb - if to_be_focused: - ui.buffer_focus(to_be_focused) - else: - ui.buffer_open(buffer.SearchBuffer(ui, self.query)) - else: - ui.notify('empty query string') - - -class PromptCommand(Command): - """starts commandprompt""" - def __init__(self, startstring=u'', **kwargs): - self.startstring = startstring - Command.__init__(self, **kwargs) - - def apply(self, ui): - ui.commandprompt(self.startstring) - - -class RefreshCommand(Command): - """refreshes the current buffer""" - def apply(self, ui): - ui.current_buffer.rebuild() - ui.update() - - -class ExternalCommand(Command): - """calls external command""" - 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 - :type in_thread: boolean - :param refocus: refocus calling buffer after cmd termination - :type refocus: boolean - :param on_success: code to execute after command successfully exited - :type on_success: callable - """ - self.commandstring = commandstring - self.path = path - self.spawn = spawn - self.refocus = refocus - self.in_thread = in_thread - self.on_success = on_success - Command.__init__(self, **kwargs) - - def apply(self, ui): - callerbuffer = ui.current_buffer - - def afterwards(data): - if callable(self.on_success) and data == 'success': - self.on_success() - if self.refocus and callerbuffer in ui.buffers: - ui.logger.info('refocussing') - ui.buffer_focus(callerbuffer) - - write_fd = ui.mainloop.watch_pipe(afterwards) - - def thread_code(*args): - 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('utf-8', errors='ignore') - ui.logger.info('calling external command: %s' % cmd) - returncode = subprocess.call(shlex.split(cmd)) - if returncode == 0: - os.write(write_fd, 'success') - - if self.in_thread: - thread = threading.Thread(target=thread_code) - thread.start() - else: - ui.mainloop.screen.stop() - thread_code() - ui.mainloop.screen.start() - - -class EditCommand(ExternalCommand): - def __init__(self, path, spawn=None, **kwargs): - self.path = path - if spawn != None: - self.spawn = spawn - else: - self.spawn = settings.config.getboolean('general', 'spawn_editor') - editor_cmd = settings.config.get('general', 'editor_cmd') - - ExternalCommand.__init__(self, editor_cmd, path=self.path, - spawn=self.spawn, in_thread=self.spawn, - **kwargs) - - -class PythonShellCommand(Command): - """opens an interactive shell for introspection""" - def apply(self, ui): - ui.mainloop.screen.stop() - code.interact(local=locals()) - ui.mainloop.screen.start() - - -class BufferCloseCommand(Command): - """close a buffer""" - def __init__(self, buffer=None, focussed=False, **kwargs): - """ - :param buffer: the selected buffer - :type buffer: `alot.buffer.Buffer` - """ - self.buffer = buffer - self.focussed = focussed - Command.__init__(self, **kwargs) - - def apply(self, ui): - if self.focussed: - #if in bufferlist, this is ugly. - self.buffer = ui.current_buffer.get_selected_buffer() - elif not self.buffer: - self.buffer = ui.current_buffer - ui.buffer_close(self.buffer) - ui.buffer_focus(ui.current_buffer) - - -class BufferFocusCommand(Command): - """focus a buffer""" - def __init__(self, buffer=None, offset=0, **kwargs): - """ - :param buffer: the buffer to focus - :type buffer: `alot.buffer.Buffer` - """ - self.buffer = buffer - self.offset = offset - Command.__init__(self, **kwargs) - - def apply(self, ui): - if self.offset: - idx = ui.buffers.index(ui.current_buffer) - num = len(ui.buffers) - self.buffer = ui.buffers[(idx + self.offset) % num] - else: - if not self.buffer: - self.buffer = ui.current_buffer.get_selected_buffer() - ui.buffer_focus(self.buffer) - - -class OpenBufferlistCommand(Command): - """open a bufferlist buffer""" - def __init__(self, filtfun=None, **kwargs): - self.filtfun = filtfun - Command.__init__(self, **kwargs) - - def apply(self, ui): - blists = ui.get_buffers_of_type(buffer.BufferlistBuffer) - if blists: - ui.buffer_focus(blists[0]) - else: - ui.buffer_open(buffer.BufferlistBuffer(ui, self.filtfun)) - - -class TagListCommand(Command): - """open a taglisat buffer""" - def __init__(self, filtfun=None, **kwargs): - self.filtfun = filtfun - Command.__init__(self, **kwargs) - - def apply(self, ui): - tags = ui.dbman.get_all_tags() - buf = buffer.TagListBuffer(ui, tags, self.filtfun) - ui.buffers.append(buf) - buf.rebuild() - ui.buffer_focus(buf) - - -class FlushCommand(Command): - """Flushes writes to the index. Retries until committed""" - def apply(self, ui): - try: - ui.dbman.flush() - except DatabaseLockedError: - timeout = settings.config.getint('general', 'flush_retry_timeout') - - def f(*args): - self.apply(ui) - ui.mainloop.set_alarm_in(timeout, f) - ui.notify('index locked, will try again in %d secs' % timeout) - ui.update() - return - - -class ToggleThreadTagCommand(Command): - """toggles tag in given or currently selected thread""" - def __init__(self, tags, thread=None, **kwargs): - assert tags - self.thread = thread - self.tags = set(tags) - Command.__init__(self, **kwargs) - - def apply(self, ui): - if not self.thread: - self.thread = ui.current_buffer.get_selected_thread() - if not self.thread: - return - try: - self.thread.set_tags(set(self.thread.get_tags()) ^ self.tags) - except DatabaseROError: - ui.notify('index in read-only mode', priority='error') - return - - # flush index - ui.apply_command(FlushCommand()) - - # update current buffer - # TODO: what if changes not yet flushed? - cb = ui.current_buffer - if isinstance(cb, buffer.SearchBuffer): - # refresh selected threadline - threadwidget = cb.get_selected_threadline() - threadwidget.rebuild() # rebuild and redraw the line - #remove line from searchlist if thread doesn't match the query - qs = "(%s) AND thread:%s" % (cb.querystring, - self.thread.get_thread_id()) - if ui.dbman.count_messages(qs) == 0: - ui.logger.debug('remove: %s' % self.thread) - cb.threadlist.remove(threadwidget) - cb.result_count -= self.thread.get_total_messages() - ui.update() - elif isinstance(cb, buffer.ThreadBuffer): - pass - - -class ComposeCommand(Command): - """compose a new email and open an envelope for it""" - def __init__(self, mail=None, headers={}, **kwargs): - Command.__init__(self, **kwargs) - if not mail: - self.mail = MIMEMultipart() - self.mail.attach(MIMEText('', 'plain', 'UTF-8')) - else: - self.mail = mail - 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 - if not 'From' in self.mail: - accounts = ui.accountman.get_accounts() - if len(accounts) == 0: - ui.notify('no accounts set') - return - elif len(accounts) == 1: - a = accounts[0] - else: - cmpl = AccountCompleter(ui.accountman) - 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 = yield ui.prompt(prefix='From>', - completer=cmpl) - if not fromaddress: - ui.notify('canceled') - return - a = ui.accountman.get_account_by_address(fromaddress) - self.mail['From'] = "%s <%s>" % (a.realname, a.address) - - #get To header - if 'To' not in self.mail: - 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 = yield ui.prompt(prefix='Subject>') - if subject == None: - ui.notify('canceled') - return - self.mail['Subject'] = encode_header('subject', subject) - - ui.apply_command(EnvelopeEditCommand(mail=self.mail)) - - -# SEARCH -class RetagPromptCommand(Command): - """start a commandprompt to retag selected threads' tags - this is needed to fill the prompt with the current tags.. - """ - def apply(self, ui): - thread = ui.current_buffer.get_selected_thread() - if not thread: - return - initial_tagstring = ','.join(thread.get_tags()) - ui.commandprompt('retag ' + initial_tagstring) - - -class RetagCommand(Command): - """tag selected thread""" - def __init__(self, tagsstring=u'', thread=None, **kwargs): - self.tagsstring = tagsstring - self.thread = thread - Command.__init__(self, **kwargs) - - def apply(self, ui): - if not self.thread: - self.thread = ui.current_buffer.get_selected_thread() - if not self.thread: - return - tags = filter(lambda x: x, self.tagsstring.split(',')) - ui.logger.info("got %s:%s" % (self.tagsstring, tags)) - try: - self.thread.set_tags(tags) - except DatabaseROError: - ui.notify('index in read-only mode', priority='error') - return - - # flush index - ui.apply_command(FlushCommand()) - - # refresh selected threadline - sbuffer = ui.current_buffer - threadwidget = sbuffer.get_selected_threadline() - threadwidget.rebuild() # rebuild and redraw the line - - -class RefineCommand(Command): - """refine the query of the currently open searchbuffer""" - def __init__(self, query=None, **kwargs): - 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 (yield ui.choice(s, select='yes', cancel='no')) == 'no': - return - sbuffer = ui.current_buffer - oldquery = sbuffer.querystring - if self.querystring not in [None, oldquery]: - sbuffer.querystring = self.querystring - sbuffer = ui.current_buffer - sbuffer.rebuild() - ui.update() - else: - ui.notify('empty query string') - - -class RefinePromptCommand(Command): - """prompt to change current search buffers query""" - def apply(self, ui): - sbuffer = ui.current_buffer - oldquery = sbuffer.querystring - ui.commandprompt('refine ' + oldquery) - - -# THREAD -class ReplyCommand(Command): - """format reply for currently selected message and open envelope for it""" - def __init__(self, message=None, groupreply=False, **kwargs): - """ - :param message: the original message to reply to - :type message: `alot.message.Message` - :param groupreply: copy other recipients from Bcc/Cc/To to the reply - :type groupreply: boolean - """ - self.message = message - self.groupreply = groupreply - Command.__init__(self, **kwargs) - - def apply(self, ui): - if not self.message: - self.message = ui.current_buffer.get_selected_message() - mail = self.message.get_email() - # set body text - 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' - - Charset.add_charset('utf-8', Charset.QP, Charset.QP, 'utf-8') - bodypart = MIMEText(mailcontent.encode('utf-8'), 'plain', 'UTF-8') - reply = MIMEMultipart() - reply.attach(bodypart) - - # copy subject - subject = mail.get('Subject', '') - if not subject.startswith('Re:'): - subject = 'Re: ' + subject - reply['Subject'] = Header(subject.encode('utf-8'), 'UTF-8').encode() - - # set From - my_addresses = ui.accountman.get_addresses() - matched_address = '' - in_to = [a for a in my_addresses if a in mail.get('To', '')] - if in_to: - matched_address = in_to[0] - else: - cc = mail.get('Cc', '') + mail.get('Bcc', '') - in_cc = [a for a in my_addresses if a in cc] - if in_cc: - matched_address = in_cc[0] - if matched_address: - account = ui.accountman.get_account_by_address(matched_address) - fromstring = '%s <%s>' % (account.realname, account.address) - reply['From'] = encode_header('From', fromstring) - - # set To - del(reply['To']) - if self.groupreply: - cleared = self.clear_my_address(my_addresses, mail.get('To', '')) - if cleared: - logging.info(mail['From'] + ', ' + cleared) - to = mail['From'] + ', ' + cleared - reply['To'] = encode_header('To', to) - logging.info(reply['To']) - else: - reply['To'] = encode_header('To', mail['From']) - # copy cc and bcc for group-replies - if 'Cc' in mail: - cc = self.clear_my_address(my_addresses, mail['Cc']) - reply['Cc'] = encode_header('Cc', cc) - if 'Bcc' in mail: - bcc = self.clear_my_address(my_addresses, mail['Bcc']) - reply['Bcc'] = encode_header('Bcc', bcc) - else: - reply['To'] = encode_header('To', mail['From']) - - # set In-Reply-To header - del(reply['In-Reply-To']) - reply['In-Reply-To'] = '<%s>' % self.message.get_message_id() - - # set References header - old_references = mail.get('References', '') - if old_references: - old_references = old_references.split() - references = old_references[-8:] - if len(old_references) > 8: - references = old_references[:1] + references - references.append('<%s>' % self.message.get_message_id()) - reply['References'] = ' '.join(references) - else: - reply['References'] = '<%s>' % self.message.get_message_id() - - ui.apply_command(ComposeCommand(mail=reply)) - - def clear_my_address(self, my_addresses, value): - new_value = [] - for entry in value.split(','): - if not [a for a in my_addresses if a in entry]: - new_value.append(entry.strip()) - return ', '.join(new_value) - - -class ForwardCommand(Command): - def __init__(self, message=None, inline=False, **kwargs): - """ - :param message: the original message to forward. If None, the currently - selected one is used - :type message: `alot.message.Message` - :param inline: Copy originals body text instead of attaching the whole - mail - :type inline: boolean - """ - self.message = message - self.inline = inline - Command.__init__(self, **kwargs) - - def apply(self, ui): - if not self.message: - self.message = ui.current_buffer.get_selected_message() - mail = self.message.get_email() - - reply = MIMEMultipart() - Charset.add_charset('utf-8', Charset.QP, Charset.QP, 'utf-8') - if self.inline: # inline mode - # set body text - 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' - - bodypart = MIMEText(mailcontent.encode('utf-8'), 'plain', 'UTF-8') - reply.attach(bodypart) - - else: # attach original mode - # create empty text msg - bodypart = MIMEText('', 'plain', 'UTF-8') - reply.attach(bodypart) - # attach original msg - reply.attach(mail) - - # copy subject - subject = mail.get('Subject', '') - subject = 'Fwd: ' + subject - reply['Subject'] = Header(subject.encode('utf-8'), 'UTF-8').encode() - - # set From - my_addresses = ui.accountman.get_addresses() - matched_address = '' - in_to = [a for a in my_addresses if a in mail.get('To', '')] - if in_to: - matched_address = in_to[0] - else: - cc = mail.get('Cc', '') + mail.get('Bcc', '') - in_cc = [a for a in my_addresses if a in cc] - if in_cc: - matched_address = in_cc[0] - if matched_address: - account = ui.accountman.get_account_by_address(matched_address) - fromstring = '%s <%s>' % (account.realname, account.address) - reply['From'] = encode_header('From', fromstring) - ui.apply_command(ComposeCommand(mail=reply)) - - -class FoldMessagesCommand(Command): - def __init__(self, all=False, visible=None, **kwargs): - self.all = all - self.visible = visible - Command.__init__(self, **kwargs) - - def apply(self, ui): - lines = [] - if not self.all: - lines.append(ui.current_buffer.get_selection()) - else: - lines = ui.current_buffer.get_message_widgets() - - for widget in lines: - # in case the thread is yet unread, remove this tag - msg = widget.get_message() - if self.visible or (self.visible == None and widget.folded): - if 'unread' in msg.get_tags(): - msg.remove_tags(['unread']) - ui.apply_command(FlushCommand()) - widget.rebuild() - widget.fold(visible=True) - else: - widget.fold(visible=False) - - -class ToggleHeaderCommand(Command): - def apply(self, ui): - msgw = ui.current_buffer.get_selection() - msgw.toggle_full_header() - - -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.cmd = command - self.whole_thread = whole_thread - self.separately = separately - self.noop_msg = noop_msg - self.confirm_msg = confirm_msg - self.done_msg = done_msg - - @defer.inlineCallbacks - def apply(self, ui): - # 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() - else: - to_print = [ui.current_buffer.get_selected_message()] - - # ask for confirmation if needed - if self.confirm_msg: - if (yield ui.choice(self.confirm_msg, select='yes', - cancel='no')) == 'no': - return - - # prepare message sources - mailstrings = [m.get_email().as_string() for m in to_print] - if not self.separately: - mailstrings = ['\n\n'.join(mailstrings)] - - # 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 - 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): - def __init__(self, all=False, path=None, **kwargs): - Command.__init__(self, **kwargs) - 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 = yield ui.prompt(prefix='save attachments to:', - text=os.path.join('~', ''), - completer=pcomplete) - if self.path: - if os.path.isdir(os.path.expanduser(self.path)): - for a in msg.get_attachments(): - dest = a.save(self.path) - 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, - priority='error') - else: - ui.notify('canceled') - else: # save focussed attachment - focus = ui.get_deep_focus() - if isinstance(focus, widgets.AttachmentWidget): - attachment = focus.get_attachment() - filename = attachment.get_filename() - if not self.path: - 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: - 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') - - -class OpenAttachmentCommand(Command): - """displays an attachment according to mailcap""" - def __init__(self, attachment, **kwargs): - Command.__init__(self, **kwargs) - self.attachment = attachment - - def apply(self, ui): - logging.info('open attachment') - mimetype = self.attachment.get_content_type() - handler = settings.get_mime_handler(mimetype) - if handler: - path = self.attachment.save(tempfile.gettempdir()) - handler = handler.replace('%s', '{}') - - def afterwards(): - os.remove(path) - ui.apply_command(ExternalCommand(handler, path=path, - on_success=afterwards, - in_thread=True)) - else: - ui.notify('unknown mime type') - - -class ThreadSelectCommand(Command): - def apply(self, ui): - focus = ui.get_deep_focus() - if isinstance(focus, widgets.MessageSummaryWidget): - ui.apply_command(FoldMessagesCommand()) - elif isinstance(focus, widgets.AttachmentWidget): - logging.info('open attachment') - ui.apply_command(OpenAttachmentCommand(focus.get_attachment())) - else: - logging.info('unknown widget %s' % focus) - - -### ENVELOPE -class EnvelopeOpenCommand(Command): - """open a new envelope buffer""" - def __init__(self, mail=None, **kwargs): - self.mail = mail - Command.__init__(self, **kwargs) - - def apply(self, ui): - ui.buffer_open(buffer.EnvelopeBuffer(ui, mail=self.mail)) - - -class EnvelopeEditCommand(Command): - """re-edits mail in from envelope buffer""" - def __init__(self, mail=None, **kwargs): - self.mail = mail - self.openNew = (mail != None) - Command.__init__(self, **kwargs) - - def apply(self, ui): - Charset.add_charset('utf-8', Charset.QP, Charset.QP, 'utf-8') - if not self.mail: - self.mail = ui.current_buffer.get_email() - - 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. - - # get input - f = open(tf.name) - enc = settings.config.get('general', 'editor_writes_encoding') - 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 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 - value += line - if key and value: # save last one if present - del self.mail[key] - self.mail[key] = encode_header(key, value) - - if self.mail.is_multipart(): - for part in self.mail.walk(): - if part.get_content_maintype() == 'text': - if 'Content-Transfer-Encoding' in part: - del(part['Content-Transfer-Encoding']) - part.set_payload(bodytext, 'utf-8') - break - - f.close() - os.unlink(tf.name) - if self.openNew: - ui.apply_command(EnvelopeOpenCommand(mail=self.mail)) - else: - ui.current_buffer.set_email(self.mail) - - # decode header - edit_headers = ['Subject', 'To', 'From'] - headertext = u'' - for key in edit_headers: - value = u'' - if key in self.mail: - value = decode_header(self.mail.get(key, '')) - headertext += '%s: %s\n' % (key, value) - - if self.mail.is_multipart(): - for part in self.mail.walk(): - if part.get_content_maintype() == 'text': - bodytext = decode_to_unicode(part) - break - 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, - bodytext) - tf.write(content.encode('utf-8')) - tf.flush() - tf.close() - cmd = EditCommand(tf.name, on_success=openEnvelopeFromTmpfile, - refocus=False) - ui.apply_command(cmd) - - -class EnvelopeSetCommand(Command): - """sets header fields of mail open in envelope buffer""" - - def __init__(self, key='', value=u'', replace=True, **kwargs): - self.key = key - self.value = encode_header(key, value) - self.replace = replace - Command.__init__(self, **kwargs) - - def apply(self, ui): - envelope = ui.current_buffer - mail = envelope.get_email() - if self.replace: - del(mail[self.key]) - mail[self.key] = self.value - envelope.rebuild() - - -class EnvelopeRefineCommand(Command): - """prompt to change current value of header field""" - - def __init__(self, key='', **kwargs): - Command.__init__(self, **kwargs) - self.key = key - - def apply(self, ui): - mail = ui.current_buffer.get_email() - value = decode_header(mail.get(self.key, '')) - ui.commandprompt('set %s %s' % (self.key, value)) - - -class EnvelopeSendCommand(Command): - @defer.inlineCallbacks - def apply(self, ui): - envelope = ui.current_buffer - mail = envelope.get_email() - frm = decode_header(mail.get('From')) - sname, saddr = email.Utils.parseaddr(frm) - account = ui.accountman.get_account_by_address(saddr) - if account: - # attach signature file if present - if account.signature: - sig = os.path.expanduser(account.signature) - if os.path.isfile(sig): - if account.signature_filename: - name = account.signature_filename - else: - name = None - helper.attach(sig, mail, filename=name) - else: - ui.notify('could not locate signature: %s' % sig, - priority='error') - if (yield ui.choice('send without signature', - select='yes', cancel='no')) == 'no': - return - - clearme = ui.notify('sending..', timeout=-1, block=False) - reason = account.send_mail(mail) - ui.clear_notify([clearme]) - if not reason: # sucessfully send mail - cmd = BufferCloseCommand(buffer=envelope) - ui.apply_command(cmd) - ui.notify('mail send successful') - else: - ui.notify('failed to send: %s' % reason, priority='error') - else: - ui.notify('failed to send: no account set up for %s' % saddr, - priority='error') - - -class EnvelopeAttachCommand(Command): - def __init__(self, path=None, mail=None, **kwargs): - Command.__init__(self, **kwargs) - self.mail = mail - self.path = path - - def apply(self, ui): - msg = self.mail - if not msg: - msg = ui.current_buffer.get_email() - - if self.path: - files = filter(os.path.isfile, - glob.glob(os.path.expanduser(self.path))) - if not files: - ui.notify('no matches, abort') - return - else: - ui.notify('no files specified, abort') - - logging.info("attaching: %s" % files) - for path in files: - helper.attach(path, msg) - - if not self.mail: # set the envelope msg iff we got it from there - ui.current_buffer.set_email(msg) - - -# TAGLIST -class TaglistSelectCommand(Command): - def apply(self, ui): - tagstring = ui.current_buffer.get_selected_tag() - cmd = SearchCommand(query='tag:%s' % tagstring) - ui.apply_command(cmd) - - -class SendKeypressCommand(Command): - def __init__(self, key, **kwargs): - Command.__init__(self, **kwargs) - self.key = key - - def apply(self, ui): - ui.keypress(self.key) - - -class HelpCommand(Command): - def __init__(self, commandline='', **kwargs): - Command.__init__(self, **kwargs) - self.commandline = commandline.strip() - - def apply(self, ui): - if self.commandline: - cmd = self.commandline.split(' ', 1)[0] - # TODO: how to I access COMMANDS from below? - ui.notify('no help for \'%s\'' % cmd, priority='error') - titletext = 'help for %s' % cmd - body = urwid.Text('helpstring') - return - else: - # get mappings - modemaps = dict(settings.config.items('%s-maps' % ui.mode)) - globalmaps = dict(settings.config.items('global-maps')) - - # build table - maxkeylength = len(max((modemaps).keys() + globalmaps.keys(), - key=len)) - keycolumnwidth = maxkeylength + 2 - - linewidgets = [] - # mode specific maps - linewidgets.append(urwid.Text(('helptexth1', - '\n%s-mode specific maps' % ui.mode))) - for (k, v) in modemaps.items(): - line = urwid.Columns([('fixed', keycolumnwidth, urwid.Text(k)), - urwid.Text(v)]) - linewidgets.append(line) - - # global maps - linewidgets.append(urwid.Text(('helptexth1', - '\nglobal maps'))) - for (k, v) in globalmaps.items(): - if k not in modemaps: - line = urwid.Columns( - [('fixed', keycolumnwidth, urwid.Text(k)), - urwid.Text(v)]) - linewidgets.append(line) - - body = urwid.ListBox(linewidgets) - ckey = 'cancel' - titletext = 'Bindings Help (%s cancels)' % ckey - - box = widgets.DialogBox(body, titletext, - bodyattr='helptext', - titleattr='helptitle') - - # put promptwidget as overlay on main widget - overlay = urwid.Overlay(box, ui.mainframe, 'center', - ('relative', 70), 'middle', - ('relative', 70)) - ui.show_as_root_until_keypress(overlay, 'cancel') - - -COMMANDS = { - 'search': { - 'refine': (RefineCommand, {}), - 'refineprompt': (RefinePromptCommand, {}), - 'openthread': (OpenThreadCommand, {}), - 'toggletag': (ToggleThreadTagCommand, {'tags': ['inbox']}), - 'retag': (RetagCommand, {}), - 'retagprompt': (RetagPromptCommand, {}), - }, - 'envelope': { - 'attach': (EnvelopeAttachCommand, {}), - 'send': (EnvelopeSendCommand, {}), - 'reedit': (EnvelopeEditCommand, {}), - 'refine': (EnvelopeRefineCommand, {}), - 'set': (EnvelopeSetCommand, {}), - }, - 'bufferlist': { - 'closefocussed': (BufferCloseCommand, {'focussed': True}), - 'openfocussed': (BufferFocusCommand, {}), - }, - 'taglist': { - 'select': (TaglistSelectCommand, {}), - }, - 'thread': { - 'reply': (ReplyCommand, {}), - 'groupreply': (ReplyCommand, {'groupreply': True}), - 'forward': (ForwardCommand, {}), - 'fold': (FoldMessagesCommand, {'visible': False}), - 'pipeto': (PipeCommand, {}), - 'print': (PrintCommand, {}), - 'unfold': (FoldMessagesCommand, {'visible': True}), - 'select': (ThreadSelectCommand, {}), - 'save': (SaveAttachmentCommand, {}), - 'toggleheaders': (ToggleHeaderCommand, {}), - }, - 'global': { - 'move': (SendKeypressCommand, {}), - 'cancel': (SendKeypressCommand, {'key': 'cancel'}), - 'select': (SendKeypressCommand, {'key': 'select'}), - 'sendkey': (SendKeypressCommand, {}), - 'bnext': (BufferFocusCommand, {'offset': 1}), - 'bprevious': (BufferFocusCommand, {'offset': -1}), - 'bufferlist': (OpenBufferlistCommand, {}), - 'bclose': (BufferCloseCommand, {}), - 'compose': (ComposeCommand, {}), - 'edit': (EditCommand, {}), - 'exit': (ExitCommand, {}), - 'flush': (FlushCommand, {}), - 'prompt': (PromptCommand, {}), - 'pyshell': (PythonShellCommand, {}), - 'refresh': (RefreshCommand, {}), - 'search': (SearchCommand, {}), - 'shellescape': (ExternalCommand, {}), - 'taglist': (TagListCommand, {}), - 'help': (HelpCommand, {}), - } -} - - -def commandfactory(cmdname, mode='global', **kwargs): - if cmdname in COMMANDS[mode]: - (cmdclass, parms) = COMMANDS[mode][cmdname] - elif cmdname in COMMANDS['global']: - (cmdclass, parms) = COMMANDS['global'][cmdname] - else: - logging.error('there is no command %s' % cmdname) - parms = parms.copy() - parms.update(kwargs) - for (key, value) in kwargs.items(): - if callable(value): - parms[key] = value() - else: - parms[key] = value - - parms['prehook'] = settings.hooks.get('pre_' + cmdname) - parms['posthook'] = settings.hooks.get('post_' + cmdname) - - logging.debug('cmd parms %s' % parms) - return cmdclass(**parms) - - -def interpret_commandline(cmdline, mode): - # TODO: use argparser here! - if not cmdline: - return None - logging.debug('mode:%s got commandline "%s"' % (mode, cmdline)) - args = cmdline.split(' ', 1) - cmd = args[0] - if args[1:]: - params = args[1] - else: - params = '' - - # unfold aliases - if settings.config.has_option('command-aliases', cmd): - cmd = settings.config.get('command-aliases', cmd) - - # allow to shellescape without a space after '!' - if cmd.startswith('!'): - params = cmd[1:] + ' ' + params - cmd = 'shellescape' - - # check if this command makes sense in current mode - if cmd not in COMMANDS[mode] and cmd not in COMMANDS['global']: - logging.debug('unknown command: %s' % (cmd)) - return None - - if cmd == 'search': - return commandfactory(cmd, mode=mode, query=params) - if cmd in ['move', 'sendkey']: - return commandfactory(cmd, mode=mode, key=params) - elif cmd == 'compose': - h = {} - if params: - h = {'To': params} - return commandfactory(cmd, mode=mode, headers=h) - elif cmd == 'attach': - return commandfactory(cmd, mode=mode, path=params) - elif cmd == 'help': - return commandfactory(cmd, mode=mode, commandline=params) - elif cmd == 'forward': - return commandfactory(cmd, mode=mode, inline=(params == '--inline')) - elif cmd == 'prompt': - return commandfactory(cmd, mode=mode, startstring=params) - elif cmd == 'refine': - if mode == 'search': - return commandfactory(cmd, mode=mode, query=params) - elif mode == 'envelope': - return commandfactory(cmd, mode=mode, key=params) - - elif cmd == 'retag': - return commandfactory(cmd, mode=mode, tagsstring=params) - elif cmd == 'shellescape': - return commandfactory(cmd, mode=mode, commandstring=params) - elif cmd == 'set': - key, value = params.split(' ', 1) - return commandfactory(cmd, mode=mode, key=key, value=value) - elif cmd == 'toggletag': - return commandfactory(cmd, mode=mode, tags=params.split()) - elif cmd == 'fold': - return commandfactory(cmd, mode=mode, all=(params == '--all')) - elif cmd == 'unfold': - return commandfactory(cmd, mode=mode, all=(params == '--all')) - elif cmd == 'save': - args = params.split(' ') - allset = False - pathset = None - if args: - if args[0] == '--all': - allset = True - pathset = ' '.join(args[1:]) - else: - pathset = params - return commandfactory(cmd, mode=mode, all=allset, path=pathset) - elif cmd == 'edit': - filepath = os.path.expanduser(params) - if os.path.isfile(filepath): - return commandfactory(cmd, mode=mode, path=filepath) - elif cmd == 'print': - args = [a.strip() for a in params.split()] - 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', - 'closefocussed', 'bnext', 'bprevious', 'retag', - 'refresh', 'bufferlist', 'refineprompt', - 'reply', 'open', 'groupreply', 'bounce', - 'openthread', 'toggleheaders', 'send', - 'cancel', 'reedit', 'select', 'retagprompt']: - return commandfactory(cmd, mode=mode) - else: - return None diff --git a/alot/commands/__init__.py b/alot/commands/__init__.py new file mode 100644 index 00000000..b374d317 --- /dev/null +++ b/alot/commands/__init__.py @@ -0,0 +1,142 @@ +import os +import sys +import glob +import shlex +import logging +import argparse +import cStringIO + +import alot.settings + + +class Command(object): + """base class for commands""" + def __init__(self, prehook=None, posthook=None): + self.prehook = prehook + self.posthook = posthook + self.undoable = False + self.help = self.__doc__ + + def apply(self, caller): + pass + + @classmethod + def get_helpstring(cls): + return cls.__doc__ + + +COMMANDS = { + 'search': {}, + 'envelope': {}, + 'bufferlist': {}, + 'taglist': {}, + 'thread': {}, + 'global': {}, +} + + +def lookup_command(cmdname, mode): + """returns commandclass, argparser and forcedparams + for `cmdname` in `mode`""" + if cmdname in COMMANDS[mode]: + return COMMANDS[mode][cmdname] + elif cmdname in COMMANDS['global']: + return COMMANDS['global'][cmdname] + else: + return None, None, None + + +def lookup_parser(cmdname, mode): + return lookup_command(cmdname, mode)[1] + + +class CommandParseError(Exception): + pass + + +class CommandArgumentParser(argparse.ArgumentParser): + """ArgumentParser that raises `CommandParseError` + instead of printing to sys.stderr""" + def exit(self, message): + raise CommandParseError(message) + + def error(self, message): + raise CommandParseError(message) + + +class registerCommand(object): + def __init__(self, mode, name, help=None, usage=None, + forced={}, arguments=[]): + self.mode = mode + self.name = name + self.help = help + self.usage = usage + self.forced = forced + self.arguments = arguments + + def __call__(self, klass): + argparser = CommandArgumentParser(description=self.help, + usage=self.usage, + prog=self.name, add_help=False) + for args, kwargs in self.arguments: + argparser.add_argument(*args, **kwargs) + COMMANDS[self.mode][self.name] = (klass, argparser, self.forced) + return klass + + +def commandfactory(cmdline, mode='global'): + # split commandname and parameters + if not cmdline: + return None + logging.debug('mode:%s got commandline "%s"' % (mode, cmdline)) + # allow to shellescape without a space after '!' + if cmdline.startswith('!'): + cmdline = 'shellescape \'%s\'' % cmdline[1:] + args = shlex.split(cmdline.encode('utf-8')) + args = map(lambda x: x.decode('utf-8'), args) # get unicode strings + logging.debug('ARGS: %s' % args) + cmdname = args[0] + args = args[1:] + + # unfold aliases + if alot.settings.config.has_option('command-aliases', cmdname): + cmdname = alot.settings.config.get('command-aliases', cmdname) + + + # get class, argparser and forced parameter + (cmdclass, parser, forcedparms) = lookup_command(cmdname, mode) + if cmdclass is None: + msg = 'unknown command: %s' % cmdname + logging.debug(msg) + raise CommandParseError(msg) + + #logging.debug(parser) + parms = vars(parser.parse_args(args)) + logging.debug('PARMS: %s' % parms) + logging.debug(parms) + + parms.update(forcedparms) + # still needed? + #for (key, value) in kwargs.items(): + # if callable(value): + # parms[key] = value() + # else: + # parms[key] = value + + parms['prehook'] = alot.settings.hooks.get('pre_' + cmdname) + parms['posthook'] = alot.settings.hooks.get('post_' + cmdname) + + logging.debug('cmd parms %s' % parms) + return cmdclass(**parms) + + +#def interpret_commandline(cmdline, mode): +# +# elif cmd == 'shellescape': +# return commandfactory(cmd, mode=mode, commandstring=params) +# elif cmd == 'edit': +# filepath = os.path.expanduser(params) +# if os.path.isfile(filepath): +# return commandfactory(cmd, mode=mode, path=filepath) + +__all__ = list(filename[:-3] for filename in glob.glob1(os.path.dirname(__file__), '*.py')) diff --git a/alot/commands/bufferlist.py b/alot/commands/bufferlist.py new file mode 100644 index 00000000..7fa2607f --- /dev/null +++ b/alot/commands/bufferlist.py @@ -0,0 +1 @@ +MODE = 'bufferlist' diff --git a/alot/commands/envelope.py b/alot/commands/envelope.py new file mode 100644 index 00000000..c5f5c96a --- /dev/null +++ b/alot/commands/envelope.py @@ -0,0 +1,225 @@ +import os +import re +import glob +import logging +import email +import tempfile +from email import Charset +from twisted.internet import defer + +from alot.commands import Command, registerCommand +from alot import settings +from alot import helper +from alot.message import decode_to_unicode +from alot.message import decode_header +from alot.message import encode_header +from alot.commands.globals import EditCommand +from alot.commands.globals import BufferCloseCommand +from alot.commands.globals import EnvelopeOpenCommand + + +MODE = 'envelope' + + +@registerCommand(MODE, 'attach', help='attach files to the mail', arguments=[ + (['path'], {'help':'file(s) to attach (accepts wildcads)'})]) +class EnvelopeAttachCommand(Command): + def __init__(self, path=None, mail=None, **kwargs): + Command.__init__(self, **kwargs) + self.mail = mail + self.path = path + + def apply(self, ui): + msg = self.mail + if not msg: + msg = ui.current_buffer.get_email() + + if self.path: + files = filter(os.path.isfile, + glob.glob(os.path.expanduser(self.path))) + if not files: + ui.notify('no matches, abort') + return + else: + ui.notify('no files specified, abort') + + logging.info("attaching: %s" % files) + for path in files: + helper.attach(path, msg) + + if not self.mail: # set the envelope msg iff we got it from there + ui.current_buffer.set_email(msg) + + +@registerCommand(MODE, 'refine', help='prompt to change the value of a header', + arguments=[ + (['key'], {'help':'header to refine'})]) +class EnvelopeRefineCommand(Command): + + def __init__(self, key='', **kwargs): + Command.__init__(self, **kwargs) + self.key = key + + def apply(self, ui): + mail = ui.current_buffer.get_email() + value = decode_header(mail.get(self.key, '')) + ui.commandprompt('set %s %s' % (self.key, value)) + + +@registerCommand(MODE, 'send', help='sends mail') +class EnvelopeSendCommand(Command): + @defer.inlineCallbacks + def apply(self, ui): + envelope = ui.current_buffer + mail = envelope.get_email() + frm = decode_header(mail.get('From')) + sname, saddr = email.Utils.parseaddr(frm) + account = ui.accountman.get_account_by_address(saddr) + if account: + # attach signature file if present + if account.signature: + sig = os.path.expanduser(account.signature) + if os.path.isfile(sig): + if account.signature_filename: + name = account.signature_filename + else: + name = None + helper.attach(sig, mail, filename=name) + else: + ui.notify('could not locate signature: %s' % sig, + priority='error') + if (yield ui.choice('send without signature', + select='yes', cancel='no')) == 'no': + return + + clearme = ui.notify('sending..', timeout=-1, block=False) + reason = account.send_mail(mail) + ui.clear_notify([clearme]) + if not reason: # sucessfully send mail + cmd = BufferCloseCommand(buffer=envelope) + ui.apply_command(cmd) + ui.notify('mail send successful') + else: + ui.notify('failed to send: %s' % reason, priority='error') + else: + ui.notify('failed to send: no account set up for %s' % saddr, + priority='error') + + +@registerCommand(MODE, 'reedit', help='edit currently open mail') +class EnvelopeEditCommand(Command): + def __init__(self, mail=None, **kwargs): + self.mail = mail + self.openNew = (mail != None) + Command.__init__(self, **kwargs) + + def apply(self, ui): + Charset.add_charset('utf-8', Charset.QP, Charset.QP, 'utf-8') + if not self.mail: + self.mail = ui.current_buffer.get_email() + + 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. + + # get input + f = open(tf.name) + enc = settings.config.get('general', 'editor_writes_encoding') + 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 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 + value += line + if key and value: # save last one if present + del self.mail[key] + self.mail[key] = encode_header(key, value) + + if self.mail.is_multipart(): + for part in self.mail.walk(): + if part.get_content_maintype() == 'text': + if 'Content-Transfer-Encoding' in part: + del(part['Content-Transfer-Encoding']) + part.set_payload(bodytext, 'utf-8') + break + + f.close() + os.unlink(tf.name) + if self.openNew: + ui.apply_command(EnvelopeOpenCommand(mail=self.mail)) + else: + ui.current_buffer.set_email(self.mail) + + # decode header + edit_headers = ['Subject', 'To', 'From'] + headertext = u'' + for key in edit_headers: + value = u'' + if key in self.mail: + value = decode_header(self.mail.get(key, '')) + headertext += '%s: %s\n' % (key, value) + + if self.mail.is_multipart(): + for part in self.mail.walk(): + if part.get_content_maintype() == 'text': + bodytext = decode_to_unicode(part) + break + 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, + bodytext) + tf.write(content.encode('utf-8')) + tf.flush() + tf.close() + cmd = EditCommand(tf.name, on_success=openEnvelopeFromTmpfile, + refocus=False) + ui.apply_command(cmd) + + +@registerCommand(MODE, 'set', help='set header value', arguments=[ + (['--replace'], {'action': 'store_true', 'help':'remove old value'}), + (['key'], {'help':'header to refine'}), + (['value'], {'nargs':'+', 'help':'value'})]) +class EnvelopeSetCommand(Command): + """sets header fields of mail open in envelope buffer""" + + def __init__(self, key, value, replace=True, **kwargs): + self.key = key + value = ' '.join(value) + self.value = encode_header(key, value) + self.replace = replace + Command.__init__(self, **kwargs) + + def apply(self, ui): + envelope = ui.current_buffer + mail = envelope.get_email() + if self.replace: + del(mail[self.key]) + mail[self.key] = self.value + envelope.rebuild() diff --git a/alot/commands/globals.py b/alot/commands/globals.py new file mode 100644 index 00000000..0da6ba18 --- /dev/null +++ b/alot/commands/globals.py @@ -0,0 +1,462 @@ +import os +import code +import threading +import subprocess +import shlex +import email +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +import urwid +from twisted.internet import defer + +from alot.commands import Command, registerCommand +from alot import buffers +from alot import settings +from alot import widgets +from alot import helper +from alot.db import DatabaseLockedError +from alot.completion import ContactsCompleter +from alot.completion import AccountCompleter +from alot.message import encode_header +from alot import commands +import argparse + +MODE = 'global' + + +@registerCommand(MODE, 'exit', help='shut alot down cleanly') +class ExitCommand(Command): + @defer.inlineCallbacks + def apply(self, ui): + if settings.config.getboolean('general', 'bug_on_exit'): + if (yield ui.choice('realy quit?', select='yes', cancel='no', + msg_position='left')) == 'no': + return + ui.exit() + + +@registerCommand(MODE, 'search', usage='search query', arguments=[ + (['query'], {'nargs':argparse.REMAINDER, 'help':'search string'})], + help='open a new search buffer') +class SearchCommand(Command): + def __init__(self, query, **kwargs): + self.query = ' '.join(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 (yield ui.choice(s, select='yes', cancel='no')) == 'no': + return + open_searches = ui.get_buffers_of_type(buffers.SearchBuffer) + to_be_focused = None + for sb in open_searches: + if sb.querystring == self.query: + to_be_focused = sb + if to_be_focused: + ui.buffer_focus(to_be_focused) + else: + ui.buffer_open(buffers.SearchBuffer(ui, self.query)) + else: + ui.notify('empty query string') + + +@registerCommand(MODE, 'prompt', help='starts commandprompt', arguments=[ + (['startwith'], {'nargs':'?', 'default':'', 'help':'initial content'})]) +class PromptCommand(Command): + def __init__(self, startwith='', **kwargs): + self.startwith = startwith + Command.__init__(self, **kwargs) + + def apply(self, ui): + ui.commandprompt(self.startwith) + + +@registerCommand(MODE, 'refresh', help='refreshes the current buffer') +class RefreshCommand(Command): + def apply(self, ui): + ui.current_buffer.rebuild() + ui.update() + + +@registerCommand(MODE, 'shellescape', arguments=[ + (['--spawn'], {'action': 'store_true', 'help':'run in terminal window'}), + (['--thread'], {'action': 'store_true', 'help':'run in separate thread'}), + (['--refocus'], {'action': 'store_true', 'help':'refocus current buffer \ + after command has finished'}), + (['cmd'], {'help':'command line to execute'})], + help='calls external command') +class ExternalCommand(Command): + def __init__(self, cmd, path=None, spawn=False, refocus=True, + thread=False, on_success=None, **kwargs): + """ + :param cmd: the command to call + :type cmd: 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 thread: run asynchronously, don't block alot + :type thread: boolean + :param refocus: refocus calling buffer after cmd termination + :type refocus: boolean + :param on_success: code to execute after command successfully exited + :type on_success: callable + """ + self.commandstring = cmd + self.path = path + self.spawn = spawn + self.refocus = refocus + self.in_thread = thread + self.on_success = on_success + Command.__init__(self, **kwargs) + + def apply(self, ui): + callerbuffer = ui.current_buffer + + def afterwards(data): + if data == 'success': + if callable(self.on_success): + self.on_success() + else: + ui.notify(data, priority='error') + if self.refocus and callerbuffer in ui.buffers: + ui.logger.info('refocussing') + ui.buffer_focus(callerbuffer) + + write_fd = ui.mainloop.watch_pipe(afterwards) + + def thread_code(*args): + 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('utf-8', errors='ignore') + ui.logger.info('calling external command: %s' % cmd) + try: + if 0 == subprocess.call(shlex.split(cmd)): + os.write(write_fd, 'success') + except OSError, e: + os.write(write_fd, str(e)) + + if self.in_thread: + thread = threading.Thread(target=thread_code) + thread.start() + else: + ui.mainloop.screen.stop() + thread_code() + ui.mainloop.screen.start() + + +#@registerCommand(MODE, 'edit', arguments=[ +# (['--nospawn'], {'action': 'store_true', 'help':'spawn '}), #todo +# (['path'], {'help':'file to edit'})] +#] +#) +class EditCommand(ExternalCommand): + """opens editor""" + def __init__(self, path, spawn=None, **kwargs): + self.path = path + if spawn != None: + self.spawn = spawn + else: + self.spawn = settings.config.getboolean('general', 'spawn_editor') + editor_cmd = settings.config.get('general', 'editor_cmd') + + ExternalCommand.__init__(self, editor_cmd, path=self.path, + spawn=self.spawn, thread=self.spawn, + **kwargs) + + +@registerCommand(MODE, 'pyshell', + help="opens an interactive python shell for introspection") +class PythonShellCommand(Command): + def apply(self, ui): + ui.mainloop.screen.stop() + code.interact(local=locals()) + ui.mainloop.screen.start() + + +@registerCommand(MODE, 'bclose', + help="close current buffer or exit if it is the last") +@registerCommand('bufferlist', 'closefocussed', forced={'focussed': True}, + help='close focussed buffer') +class BufferCloseCommand(Command): + def __init__(self, buffer=None, focussed=False, **kwargs): + """ + :param buffer: the selected buffer + :type buffer: `alot.buffers.Buffer` + """ + self.buffer = buffer + self.focussed = focussed + Command.__init__(self, **kwargs) + + def apply(self, ui): + if self.focussed: + #if in bufferlist, this is ugly. + self.buffer = ui.current_buffer.get_selected_buffer() + elif not self.buffer: + self.buffer = ui.current_buffer + ui.buffer_close(self.buffer) + ui.buffer_focus(ui.current_buffer) + + +@registerCommand(MODE, 'bprevious', forced={'offset': -1}, + help='focus previous buffer') +@registerCommand(MODE, 'bnext', forced={'offset': +1}, + help='focus next buffer') +@registerCommand('bufferlist', 'openfocussed', # todo separate + help='focus selected buffer') +class BufferFocusCommand(Command): + def __init__(self, buffer=None, offset=0, **kwargs): + """ + :param buffer: the buffer to focus + :type buffer: `alot.buffers.Buffer` + """ + self.buffer = buffer + self.offset = offset + Command.__init__(self, **kwargs) + + def apply(self, ui): + if self.offset: + idx = ui.buffers.index(ui.current_buffer) + num = len(ui.buffers) + self.buffer = ui.buffers[(idx + self.offset) % num] + else: + if not self.buffer: + self.buffer = ui.current_buffer.get_selected_buffer() + ui.buffer_focus(self.buffer) + + +@registerCommand(MODE, 'bufferlist', help='opens buffer list') +class OpenBufferlistCommand(Command): + def __init__(self, filtfun=None, **kwargs): + self.filtfun = filtfun + Command.__init__(self, **kwargs) + + def apply(self, ui): + blists = ui.get_buffers_of_type(buffers.BufferlistBuffer) + if blists: + ui.buffer_focus(blists[0]) + else: + ui.buffer_open(buffers.BufferlistBuffer(ui, self.filtfun)) + + +@registerCommand(MODE, 'taglist', help='opens taglist buffer') +class TagListCommand(Command): + def __init__(self, filtfun=None, **kwargs): + self.filtfun = filtfun + Command.__init__(self, **kwargs) + + def apply(self, ui): + tags = ui.dbman.get_all_tags() + buf = buffers.TagListBuffer(ui, tags, self.filtfun) + ui.buffers.append(buf) + buf.rebuild() + ui.buffer_focus(buf) + + +@registerCommand(MODE, 'flush', + help='Flushes write operations or retries until committed') +class FlushCommand(Command): + def apply(self, ui): + try: + ui.dbman.flush() + except DatabaseLockedError: + timeout = settings.config.getint('general', 'flush_retry_timeout') + + def f(*args): + self.apply(ui) + ui.mainloop.set_alarm_in(timeout, f) + ui.notify('index locked, will try again in %d secs' % timeout) + ui.update() + return + + +#todo choices +@registerCommand(MODE, 'help', arguments=[ + (['commandname'], {'help':'command or \'bindings\''})], + help='display help for a command. Use \'bindings\' to\ + display all keybings interpreted in current mode.', +) +class HelpCommand(Command): + def __init__(self, commandname='', **kwargs): + Command.__init__(self, **kwargs) + self.commandname = commandname + + def apply(self, ui): + ui.logger.debug('HELP') + if self.commandname == 'bindings': + # get mappings + modemaps = dict(settings.config.items('%s-maps' % ui.mode)) + globalmaps = dict(settings.config.items('global-maps')) + + # build table + maxkeylength = len(max((modemaps).keys() + globalmaps.keys(), + key=len)) + keycolumnwidth = maxkeylength + 2 + + linewidgets = [] + # mode specific maps + linewidgets.append(urwid.Text(('helptexth1', + '\n%s-mode specific maps' % ui.mode))) + for (k, v) in modemaps.items(): + line = urwid.Columns([('fixed', keycolumnwidth, urwid.Text(k)), + urwid.Text(v)]) + linewidgets.append(line) + + # global maps + linewidgets.append(urwid.Text(('helptexth1', + '\nglobal maps'))) + for (k, v) in globalmaps.items(): + if k not in modemaps: + line = urwid.Columns( + [('fixed', keycolumnwidth, urwid.Text(k)), + urwid.Text(v)]) + linewidgets.append(line) + + body = urwid.ListBox(linewidgets) + ckey = 'cancel' + titletext = 'Bindings Help (%s cancels)' % ckey + + box = widgets.DialogBox(body, titletext, + bodyattr='helptext', + titleattr='helptitle') + + # put promptwidget as overlay on main widget + overlay = urwid.Overlay(box, ui.mainframe, 'center', + ('relative', 70), 'middle', + ('relative', 70)) + ui.show_as_root_until_keypress(overlay, 'cancel') + else: + ui.logger.debug('HELP %s' % self.commandname) + parser = commands.lookup_parser(self.commandname, ui.mode) + if parser: + ui.notify(parser.format_help()) + else: + ui.notify('command not known: %s' % self.commandname) + + +@registerCommand(MODE, 'compose', help='compose a new email', + arguments=[ + (['--sender'], {'nargs': '?', 'help':'sender'}), + (['--subject'], {'nargs':'?', 'help':'subject line'}), + (['--to'], {'nargs':'+', 'help':'recipient'}), + (['--cc'], {'nargs':'+', 'help':'copy to'}), + (['--bcc'], {'nargs':'+', 'help':'blind copy to'}), +]) +class ComposeCommand(Command): + def __init__(self, mail=None, headers={}, + sender=u'', subject=u'', to=[], cc=[], bcc=[], + **kwargs): + Command.__init__(self, **kwargs) + if not mail: + self.mail = MIMEMultipart() + self.mail.attach(MIMEText('', 'plain', 'UTF-8')) + else: + self.mail = mail + for key, value in headers.items(): + self.mail[key] = encode_header(key, value) + + if sender: + self.mail['From'] = encode_header('From', sender) + if subject: + self.mail['Subject'] = encode_header('Subject', subject) + if to: + self.mail['To'] = encode_header('To', ','.join(to)) + if cc: + self.mail['Cc'] = encode_header('Cc', ','.join(cc)) + if bcc: + self.mail['Bcc'] = encode_header('Bcc', ','.join(bcc)) + + @defer.inlineCallbacks + def apply(self, ui): + # TODO: fill with default header (per account) + # get From header + if not 'From' in self.mail: + accounts = ui.accountman.get_accounts() + if len(accounts) == 0: + ui.notify('no accounts set') + return + elif len(accounts) == 1: + a = accounts[0] + else: + cmpl = AccountCompleter(ui.accountman) + 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 = yield ui.prompt(prefix='From>', + completer=cmpl) + if not fromaddress: + ui.notify('canceled') + return + a = ui.accountman.get_account_by_address(fromaddress) + self.mail['From'] = "%s <%s>" % (a.realname, a.address) + + #get To header + if 'To' not in self.mail: + 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 = yield ui.prompt(prefix='Subject>') + if subject == None: + ui.notify('canceled') + return + self.mail['Subject'] = encode_header('subject', subject) + + ui.apply_command(commands.envelope.EnvelopeEditCommand(mail=self.mail)) + + +@registerCommand(MODE, 'move', help='move focus', arguments=[ + (['key'], {'nargs':'+', 'help':'direction'})]) +@registerCommand(MODE, 'cancel', help='send cancel event', + forced={'key': 'cancel'}) +@registerCommand(MODE, 'select', help='send select event', + forced={'key': 'select'}) +class SendKeypressCommand(Command): + def __init__(self, key, **kwargs): + Command.__init__(self, **kwargs) + if isinstance(key, list): + key = ' '.join(key) + self.key = key + + def apply(self, ui): + ui.keypress(self.key) + + +class EnvelopeOpenCommand(Command): + """open a new envelope buffer""" + def __init__(self, mail=None, **kwargs): + self.mail = mail + Command.__init__(self, **kwargs) + + def apply(self, ui): + ui.buffer_open(buffers.EnvelopeBuffer(ui, mail=self.mail)) diff --git a/alot/commands/search.py b/alot/commands/search.py new file mode 100644 index 00000000..291e4c03 --- /dev/null +++ b/alot/commands/search.py @@ -0,0 +1,150 @@ +from alot.commands import Command, registerCommand +from twisted.internet import defer +import argparse + + +from alot.db import DatabaseROError +from alot import commands +from alot import buffers + +MODE = 'search' + + +@registerCommand(MODE, 'openthread', # todo: make this a select + help='open a new thread buffer') +class OpenThreadCommand(Command): + def __init__(self, thread=None, **kwargs): + self.thread = thread + Command.__init__(self, **kwargs) + + def apply(self, ui): + if not self.thread: + self.thread = ui.current_buffer.get_selected_thread() + if self.thread: + query = ui.current_buffer.querystring + ui.logger.info('open thread view for %s' % self.thread) + + sb = buffers.ThreadBuffer(ui, self.thread) + ui.buffer_open(sb) + sb.unfold_matching(query) + + +@registerCommand(MODE, 'toggletag', arguments=[ + (['tag'], {'nargs':'+', 'default':'', 'help':'tag to flip'})], + help='toggles tags in selected thread') +class ToggleThreadTagCommand(Command): + def __init__(self, tag, thread=None, **kwargs): + assert tag + self.thread = thread + self.tags = set(tag) + Command.__init__(self, **kwargs) + + def apply(self, ui): + if not self.thread: + self.thread = ui.current_buffer.get_selected_thread() + if not self.thread: + return + try: + self.thread.set_tags(set(self.thread.get_tags()) ^ self.tags) + except DatabaseROError: + ui.notify('index in read-only mode', priority='error') + return + + # flush index + ui.apply_command(commands.globals.FlushCommand()) + + # update current buffer + # TODO: what if changes not yet flushed? + cb = ui.current_buffer + if isinstance(cb, buffers.SearchBuffer): + # refresh selected threadline + threadwidget = cb.get_selected_threadline() + threadwidget.rebuild() # rebuild and redraw the line + #remove line from searchlist if thread doesn't match the query + qs = "(%s) AND thread:%s" % (cb.querystring, + self.thread.get_thread_id()) + if ui.dbman.count_messages(qs) == 0: + ui.logger.debug('remove: %s' % self.thread) + cb.threadlist.remove(threadwidget) + cb.result_count -= self.thread.get_total_messages() + ui.update() + elif isinstance(cb, buffers.ThreadBuffer): + pass + + +@registerCommand(MODE, 'refine', usage='refine query', arguments=[ + (['query'], {'nargs':argparse.REMAINDER, 'help':'search string'})], + help='refine the query of the currently open searchbuffer') +class RefineCommand(Command): + def __init__(self, query=None, **kwargs): + self.querystring = ' '.join(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 (yield ui.choice(s, select='yes', cancel='no')) == 'no': + return + sbuffer = ui.current_buffer + oldquery = sbuffer.querystring + if self.querystring not in [None, oldquery]: + sbuffer.querystring = self.querystring + sbuffer = ui.current_buffer + sbuffer.rebuild() + ui.update() + else: + ui.notify('empty query string') + + +@registerCommand(MODE, 'refineprompt', + help='prompt to change current search buffers query') +class RefinePromptCommand(Command): + def apply(self, ui): + sbuffer = ui.current_buffer + oldquery = sbuffer.querystring + ui.commandprompt('refine ' + oldquery) + + +@registerCommand(MODE, 'retagprompt', + help='prompt to retag selected threads\' tags') +class RetagPromptCommand(Command): + def apply(self, ui): + thread = ui.current_buffer.get_selected_thread() + if not thread: + return + initial_tagstring = ','.join(thread.get_tags()) + ui.commandprompt('retag ' + initial_tagstring) + + +@registerCommand(MODE, 'retag', arguments=[ + (['tags'], {'help':'comma separated list of tags'})], + help='overwrite selected thread\'s tags') +class RetagCommand(Command): + def __init__(self, tags=u'', thread=None, **kwargs): + self.tagsstring = tags + self.thread = thread + Command.__init__(self, **kwargs) + + def apply(self, ui): + if not self.thread: + self.thread = ui.current_buffer.get_selected_thread() + if not self.thread: + return + tags = filter(lambda x: x, self.tagsstring.split(',')) + ui.logger.info("got %s:%s" % (self.tagsstring, tags)) + try: + self.thread.set_tags(tags) + except DatabaseROError: + ui.notify('index in read-only mode', priority='error') + return + + # flush index + ui.apply_command(commands.globals.FlushCommand()) + + # refresh selected threadline + sbuffer = ui.current_buffer + threadwidget = sbuffer.get_selected_threadline() + # rebuild and redraw the line + threadwidget.rebuild() diff --git a/alot/commands/taglist.py b/alot/commands/taglist.py new file mode 100644 index 00000000..fd8a52d8 --- /dev/null +++ b/alot/commands/taglist.py @@ -0,0 +1,12 @@ +from alot.commands import Command, registerCommand +from alot.commands.globals import SearchCommand + +MODE = 'taglist' + + +@registerCommand(MODE, 'select', help='open search for selected tag') +class TaglistSelectCommand(Command): + def apply(self, ui): + tagstring = ui.current_buffer.get_selected_tag() + cmd = SearchCommand(query='tag:%s' % tagstring) + ui.apply_command(cmd) diff --git a/alot/commands/thread.py b/alot/commands/thread.py new file mode 100644 index 00000000..add2b055 --- /dev/null +++ b/alot/commands/thread.py @@ -0,0 +1,412 @@ +import os +import logging +import tempfile +from email import Charset +from email.header import Header +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from twisted.internet import defer + +from alot.commands import Command, registerCommand +from alot.commands.globals import ExternalCommand +from alot.commands.globals import FlushCommand +from alot.commands.globals import ComposeCommand +from alot import settings +from alot import widgets +from alot import completion +from alot import helper +from alot.message import encode_header + +MODE = 'thread' + + +@registerCommand(MODE, 'reply', arguments=[ + (['--all'], {'action':'store_true', 'help':'reply to all'})], + help='reply to currently selected message') +class ReplyCommand(Command): + def __init__(self, message=None, all=False, **kwargs): + """ + :param message: the original message to reply to + :type message: `alot.message.Message` + :param groupreply: copy other recipients from Bcc/Cc/To to the reply + :type groupreply: boolean + """ + self.message = message + self.groupreply = all + Command.__init__(self, **kwargs) + + def apply(self, ui): + if not self.message: + self.message = ui.current_buffer.get_selected_message() + mail = self.message.get_email() + # set body text + 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' + + Charset.add_charset('utf-8', Charset.QP, Charset.QP, 'utf-8') + bodypart = MIMEText(mailcontent.encode('utf-8'), 'plain', 'UTF-8') + reply = MIMEMultipart() + reply.attach(bodypart) + + # copy subject + subject = mail.get('Subject', '') + if not subject.startswith('Re:'): + subject = 'Re: ' + subject + reply['Subject'] = Header(subject.encode('utf-8'), 'UTF-8').encode() + + # set From + my_addresses = ui.accountman.get_addresses() + matched_address = '' + in_to = [a for a in my_addresses if a in mail.get('To', '')] + if in_to: + matched_address = in_to[0] + else: + cc = mail.get('Cc', '') + mail.get('Bcc', '') + in_cc = [a for a in my_addresses if a in cc] + if in_cc: + matched_address = in_cc[0] + if matched_address: + account = ui.accountman.get_account_by_address(matched_address) + fromstring = '%s <%s>' % (account.realname, account.address) + reply['From'] = encode_header('From', fromstring) + + # set To + del(reply['To']) + if self.groupreply: + cleared = self.clear_my_address(my_addresses, mail.get('To', '')) + if cleared: + logging.info(mail['From'] + ', ' + cleared) + to = mail['From'] + ', ' + cleared + reply['To'] = encode_header('To', to) + logging.info(reply['To']) + else: + reply['To'] = encode_header('To', mail['From']) + # copy cc and bcc for group-replies + if 'Cc' in mail: + cc = self.clear_my_address(my_addresses, mail['Cc']) + reply['Cc'] = encode_header('Cc', cc) + if 'Bcc' in mail: + bcc = self.clear_my_address(my_addresses, mail['Bcc']) + reply['Bcc'] = encode_header('Bcc', bcc) + else: + reply['To'] = encode_header('To', mail['From']) + + # set In-Reply-To header + del(reply['In-Reply-To']) + reply['In-Reply-To'] = '<%s>' % self.message.get_message_id() + + # set References header + old_references = mail.get('References', '') + if old_references: + old_references = old_references.split() + references = old_references[-8:] + if len(old_references) > 8: + references = old_references[:1] + references + references.append('<%s>' % self.message.get_message_id()) + reply['References'] = ' '.join(references) + else: + reply['References'] = '<%s>' % self.message.get_message_id() + + ui.apply_command(ComposeCommand(mail=reply)) + + def clear_my_address(self, my_addresses, value): + new_value = [] + for entry in value.split(','): + if not [a for a in my_addresses if a in entry]: + new_value.append(entry.strip()) + return ', '.join(new_value) + + +@registerCommand(MODE, 'forward', arguments=[ + (['--attach'], {'action':'store_true', 'help':'attach original mail'})], + help='forward currently selected message') +class ForwardCommand(Command): + def __init__(self, message=None, attach=True, **kwargs): + """ + :param message: the original message to forward. If None, the currently + selected one is used + :type message: `alot.message.Message` + :param attach: attach original mail instead of inline quoting its body + :type attach: boolean + """ + self.message = message + self.inline = not attach + Command.__init__(self, **kwargs) + + def apply(self, ui): + if not self.message: + self.message = ui.current_buffer.get_selected_message() + mail = self.message.get_email() + + reply = MIMEMultipart() + Charset.add_charset('utf-8', Charset.QP, Charset.QP, 'utf-8') + if self.inline: # inline mode + # set body text + 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' + + bodypart = MIMEText(mailcontent.encode('utf-8'), 'plain', 'UTF-8') + reply.attach(bodypart) + + else: # attach original mode + # create empty text msg + bodypart = MIMEText('', 'plain', 'UTF-8') + reply.attach(bodypart) + # attach original msg + reply.attach(mail) + + # copy subject + subject = mail.get('Subject', '') + subject = 'Fwd: ' + subject + reply['Subject'] = Header(subject.encode('utf-8'), 'UTF-8').encode() + + # set From + my_addresses = ui.accountman.get_addresses() + matched_address = '' + in_to = [a for a in my_addresses if a in mail.get('To', '')] + if in_to: + matched_address = in_to[0] + else: + cc = mail.get('Cc', '') + mail.get('Bcc', '') + in_cc = [a for a in my_addresses if a in cc] + if in_cc: + matched_address = in_cc[0] + if matched_address: + account = ui.accountman.get_account_by_address(matched_address) + fromstring = '%s <%s>' % (account.realname, account.address) + reply['From'] = encode_header('From', fromstring) + ui.apply_command(ComposeCommand(mail=reply)) + + +@registerCommand(MODE, 'fold', forced={'visible': False}, arguments=[ + (['--all'], {'action': 'store_true', 'help':'fold all messages'})], + help='fold message(s)') +@registerCommand(MODE, 'unfold', forced={'visible': True}, arguments=[ + (['--all'], {'action': 'store_true', 'help':'unfold all messages'})], + help='unfold message(s)') +class FoldMessagesCommand(Command): + def __init__(self, all=False, visible=None, **kwargs): + self.all = all + self.visible = visible + Command.__init__(self, **kwargs) + + def apply(self, ui): + lines = [] + if not self.all: + lines.append(ui.current_buffer.get_selection()) + else: + lines = ui.current_buffer.get_message_widgets() + + for widget in lines: + # in case the thread is yet unread, remove this tag + msg = widget.get_message() + if self.visible or (self.visible == None and widget.folded): + if 'unread' in msg.get_tags(): + msg.remove_tags(['unread']) + ui.apply_command(FlushCommand()) + widget.rebuild() + widget.fold(visible=True) + else: + widget.fold(visible=False) + + +@registerCommand(MODE, 'toggleheaders', + help='toggle display of all headers') +class ToggleHeaderCommand(Command): + def apply(self, ui): + msgw = ui.current_buffer.get_selection() + msgw.toggle_full_header() + + +@registerCommand(MODE, 'pipeto', arguments=[ + (['cmd'], {'help':'shellcommand to pipe to'}), + (['--all'], {'action': 'store_true', 'help':'pass all messages'}), + (['--separately'], {'action': 'store_true', + 'help':'call command once for each message'})], + help='pipe message(s) to stdin of a shellcommand') +class PipeCommand(Command): + def __init__(self, cmd, all=False, separately=False, + noop_msg='no command specified', confirm_msg='', + done_msg='done', **kwargs): + Command.__init__(self, **kwargs) + self.cmd = cmd + self.whole_thread = all + self.separately = separately + self.noop_msg = noop_msg + self.confirm_msg = confirm_msg + self.done_msg = done_msg + + @defer.inlineCallbacks + def apply(self, ui): + # 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() + else: + to_print = [ui.current_buffer.get_selected_message()] + + # ask for confirmation if needed + if self.confirm_msg: + if (yield ui.choice(self.confirm_msg, select='yes', + cancel='no')) == 'no': + return + + # prepare message sources + mailstrings = [m.get_email().as_string() for m in to_print] + if not self.separately: + mailstrings = ['\n\n'.join(mailstrings)] + + # 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 + if self.done_msg: + ui.notify(self.done_msg) + + +@registerCommand(MODE, 'print', arguments=[ + (['--all'], {'action': 'store_true', 'help':'print all messages'}), + (['--separately'], {'action': 'store_true', + 'help':'call print command once for each message'})], + help='print message(s)') +class PrintCommand(PipeCommand): + def __init__(self, all=False, separately=False, **kwargs): + # get print command + cmd = settings.config.get('general', 'print_cmd', fallback='') + + # set up notification strings + if all: + 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, all=all, + separately=separately, + noop_msg=noop_msg, confirm_msg=confirm_msg, + done_msg=ok_msg, **kwargs) + + +@registerCommand(MODE, 'save', arguments=[ + (['--all'], {'action': 'store_true', 'help':'save all attachments'}), + (['path'], {'nargs':'?', 'help':'path to save to'})], + help='save attachment(s)') +class SaveAttachmentCommand(Command): + def __init__(self, all=False, path=None, **kwargs): + Command.__init__(self, **kwargs) + 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 = yield ui.prompt(prefix='save attachments to:', + text=os.path.join('~', ''), + completer=pcomplete) + if self.path: + if os.path.isdir(os.path.expanduser(self.path)): + for a in msg.get_attachments(): + dest = a.save(self.path) + 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, + priority='error') + else: + ui.notify('canceled') + else: # save focussed attachment + focus = ui.get_deep_focus() + if isinstance(focus, widgets.AttachmentWidget): + attachment = focus.get_attachment() + filename = attachment.get_filename() + if not self.path: + 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: + 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') + + +class OpenAttachmentCommand(Command): + """displays an attachment according to mailcap""" + def __init__(self, attachment, **kwargs): + Command.__init__(self, **kwargs) + self.attachment = attachment + + def apply(self, ui): + logging.info('open attachment') + mimetype = self.attachment.get_content_type() + handler = settings.get_mime_handler(mimetype) + if handler: + path = self.attachment.save(tempfile.gettempdir()) + handler = handler.replace('%s', '{}') + + def afterwards(): + os.remove(path) + ui.apply_command(ExternalCommand(handler, path=path, + on_success=afterwards, + thread=True)) + else: + ui.notify('unknown mime type') + + +@registerCommand(MODE, 'select') +class ThreadSelectCommand(Command): + def apply(self, ui): + focus = ui.get_deep_focus() + if isinstance(focus, widgets.MessageSummaryWidget): + ui.apply_command(FoldMessagesCommand()) + elif isinstance(focus, widgets.AttachmentWidget): + logging.info('open attachment') + ui.apply_command(OpenAttachmentCommand(focus.get_attachment())) + else: + logging.info('unknown widget %s' % focus) diff --git a/alot/completion.py b/alot/completion.py index 43796e5c..c29ed978 100644 --- a/alot/completion.py +++ b/alot/completion.py @@ -22,7 +22,7 @@ import os import glob import logging -import command +import alot.commands as commands class Completer(object): @@ -158,8 +158,8 @@ class CommandCompleter(Completer): #TODO refine <tab> should get current querystring commandprefix = original[:pos] logging.debug('original="%s" prefix="%s"' % (original, commandprefix)) - cmdlist = command.COMMANDS['global'].copy() - cmdlist.update(command.COMMANDS[self.mode]) + cmdlist = commands.COMMANDS['global'].copy() + cmdlist.update(commands.COMMANDS[self.mode]) matching = [t for t in cmdlist if t.startswith(commandprefix)] return [(t, len(t)) for t in matching] diff --git a/alot/defaults/alot.rc b/alot/defaults/alot.rc index 63bf6ad9..2641fb87 100644 --- a/alot/defaults/alot.rc +++ b/alot/defaults/alot.rc @@ -70,17 +70,17 @@ esc = cancel enter = select @ = refresh -? = help +? = help bindings I = search tag:inbox AND NOT tag:killed L = taglist shift tab = bprevious U = search tag:unread tab = bnext -\ = 'prompt search ' +\ = prompt 'search ' d = bclose $ = flush m = compose -o = 'prompt search ' +o = prompt 'search ' q = exit ';' = bufferlist colon = prompt @@ -92,6 +92,7 @@ select = openfocussed [search-maps] a = toggletag inbox & = toggletag killed +! = toggletag flagged l = retagprompt O = refineprompt select = openthread @@ -112,10 +113,10 @@ select = reedit C = fold --all E = unfold --all H = toggleheaders -P = print --thread +P = print --all S = save --all a = toggletag inbox -g = groupreply +g = reply --all f = forward p = print s = save diff --git a/alot/init.py b/alot/init.py index 5b479d35..0184a6aa 100755 --- a/alot/init.py +++ b/alot/init.py @@ -25,7 +25,9 @@ import settings from account import AccountManager from db import DBManager from ui import UI -from command import interpret_commandline +import alot.commands as commands +from commands import * +from alot.commands import CommandParseError def parse_args(): @@ -91,6 +93,7 @@ def main(): logging.basicConfig(level=numeric_loglevel, filename=logfilename) logger = logging.getLogger() + #logger.debug(commands.COMMANDS) #accountman aman = AccountManager(settings.config) @@ -98,13 +101,15 @@ def main(): dbman = DBManager(path=args.db_path, ro=args.read_only) # get initial searchstring - if args.command != '': - cmd = interpret_commandline(args.command, 'global') - if cmd is None: - sys.exit('Invalid command: ' + args.command) - else: - default_commandline = settings.config.get('general', 'initial_command') - cmd = interpret_commandline(default_commandline, 'global') + try: + if args.command != '': + cmd = commands.commandfactory(args.command, 'global') + else: + default_commandline = settings.config.get('general', + 'initial_command') + cmd = commands.commandfactory(default_commandline, 'global') + except CommandParseError, e: + sys.exit(e) # set up and start interface UI(dbman, @@ -20,10 +20,10 @@ import urwid from twisted.internet import reactor, defer from settings import config -from buffer import BufferlistBuffer -import command -from command import commandfactory -from command import interpret_commandline +from buffers import BufferlistBuffer +import commands +from commands import commandfactory +from alot.commands import CommandParseError import widgets from completion import CommandLineCompleter @@ -44,7 +44,7 @@ class InputWrap(urwid.WidgetWrap): def allowed_command(self, cmd): if not self.select_cancel_only: return True - elif isinstance(cmd, command.SendKeypressCommand): + elif isinstance(cmd, commands.globals.SendKeypressCommand): if cmd.key in ['select', 'cancel']: return True else: @@ -57,10 +57,13 @@ class InputWrap(urwid.WidgetWrap): mode = 'global' cmdline = config.get_mapping(mode, key) if cmdline: - cmd = interpret_commandline(cmdline, mode) - if self.allowed_command(cmd): - self.ui.apply_command(cmd) - return None + try: + cmd = commandfactory(cmdline, mode) + if self.allowed_command(cmd): + self.ui.apply_command(cmd) + return None + except CommandParseError, e: + self.ui.notify(e.message, priority='error') self.ui.logger.debug('relaying key: %s' % key) return self._w.keypress(size, key) @@ -191,11 +194,11 @@ class UI(object): if cmdline: mode = self.current_buffer.typename self.commandprompthistory.append(cmdline) - cmd = interpret_commandline(cmdline, mode) - if cmd: + try: + cmd = commandfactory(cmdline, mode) self.apply_command(cmd) - else: - self.notify('invalid command') + except CommandParseError, e: + self.notify(e.message, priority='error') def buffer_open(self, b): """ @@ -9,7 +9,7 @@ setup(name='alot', author=alot.__author__, author_email=alot.__author_email__, url=alot.__url__, - packages=['alot'], + packages=['alot', 'alot.commands'], package_data={'alot': ['defaults/alot.rc', 'defaults/notmuch.rc']}, scripts=['bin/alot'], license=alot.__copyright__, |