From 911bd81d47b3c8246a8dfe5f113a01eda9f3cce0 Mon Sep 17 00:00:00 2001 From: pacien Date: Sat, 9 May 2020 16:48:25 +0200 Subject: [PATCH 1/2] commands/global/shellescape: add on_exit callback Completing the already existing on_success for callbacks that should be executed even after a non-zero exit code. --- alot/commands/globals.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/alot/commands/globals.py b/alot/commands/globals.py index aa1fd4368..89134e9a3 100644 --- a/alot/commands/globals.py +++ b/alot/commands/globals.py @@ -190,7 +190,8 @@ class ExternalCommand(Command): repeatable = True def __init__(self, cmd, stdin=None, shell=False, spawn=False, - refocus=True, thread=False, on_success=None, **kwargs): + refocus=True, thread=False, on_success=None, on_exit=None, + **kwargs): """ :param cmd: the command to call :type cmd: list or str @@ -206,6 +207,8 @@ def __init__(self, cmd, stdin=None, shell=False, spawn=False, :type refocus: bool :param on_success: code to execute after command successfully exited :type on_success: callable + :param on_exit: code to execute after command exited + :type on_exit: callable """ logging.debug({'spawn': spawn}) # make sure cmd is a list of str @@ -238,6 +241,7 @@ def __init__(self, cmd, stdin=None, shell=False, spawn=False, self.refocus = refocus self.in_thread = thread self.on_success = on_success + self.on_exit = on_exit Command.__init__(self, **kwargs) async def apply(self, ui): @@ -308,6 +312,10 @@ async def apply(self, ui): proc.returncode, ret or "No stderr output") ui.notify(msg, priority='error') + + if self.on_exit is not None: + self.on_exit() + if self.refocus and callerbuffer in ui.buffers: logging.info('refocussing') ui.buffer_focus(callerbuffer) From ebbb23c30a8dafb708c6ca6a8d513f8882be9957 Mon Sep 17 00:00:00 2001 From: pacien Date: Sat, 9 May 2020 16:53:00 +0200 Subject: [PATCH 2/2] commands/thread/open-attachment: add command This allows the user to open an attachment file with a program of their choice. GitHub: closes #1494 --- alot/commands/thread.py | 163 +++++++++++++++++++---------- docs/source/usage/modes/thread.rst | 14 +++ 2 files changed, 124 insertions(+), 53 deletions(-) diff --git a/alot/commands/thread.py b/alot/commands/thread.py index 946763bdf..ca64f4a54 100644 --- a/alot/commands/thread.py +++ b/alot/commands/thread.py @@ -934,72 +934,130 @@ async def apply(self, ui): raise CommandCanceled() +@registerCommand(MODE, 'open-attachment', arguments=[ + (['cmd'], { + 'help': '''Shell command to use to open the attachment. \ + The path to the attachment file will be passed as an argument. \ + If absent, mailcap is used to select the command \ + based on the attachment's MIME type.''', + 'nargs': '*', + }), + (['--thread'], { + 'action': 'store_true', + 'help': 'run in separate thread', + }), + (['--spawn'], { + 'action': 'store_true', + 'help': 'run in a new terminal window', + }), +]) class OpenAttachmentCommand(Command): + """opens an attachment with a given shell command + or according to mailcap""" - """displays an attachment according to mailcap""" - def __init__(self, attachment, **kwargs): + def __init__(self, cmd=None, thread=False, spawn=False, **kwargs): """ - :param attachment: attachment to open - :type attachment: :class:`~alot.db.attachment.Attachment` + :param cmd: shell command to use to open the attachment + :type cmd: list of str + :param thread: whether to run in a separate thread + :type thread: bool + :param spawn: whether to run in a new terminal window + :type spawn: bool """ Command.__init__(self, **kwargs) - self.attachment = attachment + self.cmd = cmd + self.thread = thread + self.spawn = spawn async def apply(self, ui): - logging.info('open attachment') - mimetype = self.attachment.get_content_type() + try: + logging.info('open attachment') + attachment = self._get_attachment(ui) + external_handler = \ + self._get_ext_cmd_handler(attachment) if self.cmd \ + else self._get_mailcap_cmd_handler(attachment) + + await ui.apply_command(external_handler) + + except RuntimeError as error: + ui.notify(str(error), priority='error') + + def _get_ext_cmd_handler(self, attachment): + temp_file_name, destructor = self._make_temp_file(attachment) + return ExternalCommand(self.cmd + [temp_file_name], + on_exit=destructor, + thread=self.thread, + spawn=self.spawn) + + def _get_mailcap_cmd_handler(self, attachment): + mimetype = attachment.get_content_type() # returns pair of preliminary command string and entry dict containing # more info. We only use the dict and construct the command ourselves _, entry = settings.mailcap_find_match(mimetype) - if entry: - afterwards = None # callback, will rm tempfile if used - handler_stdin = None - tempfile_name = None - handler_raw_commandstring = entry['view'] - # read parameter - part = self.attachment.get_mime_representation() - parms = tuple('='.join(p) for p in part.get_params()) - - # in case the mailcap defined command contains no '%s', - # we pipe the files content to the handling command via stdin - if '%s' in handler_raw_commandstring: - nametemplate = entry.get('nametemplate', '%s') - prefix, suffix = parse_mailcap_nametemplate(nametemplate) - - fn_hook = settings.get_hook('sanitize_attachment_filename') - if fn_hook: - # get filename - filename = self.attachment.get_filename() - prefix, suffix = fn_hook(filename, prefix, suffix) - - with tempfile.NamedTemporaryFile(delete=False, prefix=prefix, - suffix=suffix) as tmpfile: - tempfile_name = tmpfile.name - self.attachment.write(tmpfile) - - def afterwards(): - os.unlink(tempfile_name) - else: - handler_stdin = BytesIO() - self.attachment.write(handler_stdin) + if not entry: + raise RuntimeError( + f'no mailcap handler found for MIME type {mimetype}') + + afterwards = None # callback, will rm tempfile if used + handler_stdin = None + tempfile_name = None + handler_raw_commandstring = entry['view'] + # read parameter + part = attachment.get_mime_representation() + parms = tuple('='.join(p) for p in part.get_params()) + + # in case the mailcap defined command contains no '%s', + # we pipe the files content to the handling command via stdin + if '%s' in handler_raw_commandstring: + nametemplate = entry.get('nametemplate', '%s') + prefix, suffix = parse_mailcap_nametemplate(nametemplate) + tempfile_name, afterwards = \ + self._make_temp_file(attachment, prefix, suffix) + else: + handler_stdin = BytesIO() + attachment.write(handler_stdin) - # create handler command list - handler_cmd = mailcap.subst(handler_raw_commandstring, mimetype, - filename=tempfile_name, plist=parms) + # create handler command list + handler_cmd = mailcap.subst(handler_raw_commandstring, mimetype, + filename=tempfile_name, plist=parms) - handler_cmdlist = split_commandstring(handler_cmd) + handler_cmdlist = split_commandstring(handler_cmd) - # 'needsterminal' makes handler overtake the terminal - # XXX: could this be repalced with "'needsterminal' not in entry"? - overtakes = entry.get('needsterminal') is None + # 'needsterminal' makes handler overtake the terminal + # XXX: could this be replaced with "'needsterminal' not in entry"? + overtakes = entry.get('needsterminal') is None - await ui.apply_command(ExternalCommand(handler_cmdlist, - stdin=handler_stdin, - on_success=afterwards, - thread=overtakes)) + return ExternalCommand(handler_cmdlist, + stdin=handler_stdin, + on_exit=afterwards, + thread=overtakes, + spawn=self.spawn) + + @staticmethod + def _get_attachment(ui): + focus = ui.get_deep_focus() + if isinstance(focus, AttachmentWidget): + return focus.get_attachment() + elif (getattr(focus, 'mimepart', False) and + isinstance(focus.mimepart, Attachment)): + return focus.mimepart else: - ui.notify('unknown mime type') + raise RuntimeError('not focused on an attachment') + + @staticmethod + def _make_temp_file(attachment, prefix='', suffix=''): + filename = attachment.get_filename() + sanitize_hook = settings.get_hook('sanitize_attachment_filename') + prefix, suffix = \ + sanitize_hook(filename, prefix, suffix) \ + if sanitize_hook else '', '' + + with tempfile.NamedTemporaryFile(delete=False, prefix=prefix, + suffix=suffix) as tmpfile: + logging.info(f'created temp file {tmpfile.name}') + attachment.write(tmpfile) + return tmpfile.name, lambda: os.unlink(tmpfile.name) @registerCommand( @@ -1061,11 +1119,10 @@ class ThreadSelectCommand(Command): async def apply(self, ui): focus = ui.get_deep_focus() if isinstance(focus, AttachmentWidget): - logging.info('open attachment') - await ui.apply_command(OpenAttachmentCommand(focus.get_attachment())) + await ui.apply_command(OpenAttachmentCommand()) elif getattr(focus, 'mimepart', False): if isinstance(focus.mimepart, Attachment): - await ui.apply_command(OpenAttachmentCommand(focus.mimepart)) + await ui.apply_command(OpenAttachmentCommand()) else: await ui.apply_command(ChangeDisplaymodeCommand( mimepart=True, mimetree='toggle')) diff --git a/docs/source/usage/modes/thread.rst b/docs/source/usage/modes/thread.rst index cc389d5de..0fcaf3f30 100644 --- a/docs/source/usage/modes/thread.rst +++ b/docs/source/usage/modes/thread.rst @@ -61,6 +61,20 @@ The following commands are available in thread mode: up, down, [half]page up, [half]page down, first, last, parent, first reply, last reply, next sibling, previous sibling, next, previous, next unfolded, previous unfolded, next NOTMUCH_QUERY, previous NOTMUCH_QUERY +.. _cmd.thread.open-attachment: + +.. describe:: open-attachment + + opens an attachment with a given shell command + or according to mailcap + + argument + Shell command to use to open the attachment. The path to the attachment file will be passed as an argument. If absent, mailcap is used to select the command based on the attachment's MIME type. + + optional arguments + :---thread: run in separate thread + :---spawn: run in a new terminal window + .. _cmd.thread.pipeto: .. describe:: pipeto