From a6fa49f581a117f8344b700ba5ca522206b278b5 Mon Sep 17 00:00:00 2001 From: "martin f. krafft" Date: Tue, 22 Aug 2023 23:01:50 +1200 Subject: [PATCH 1/1] WIP buildmimetree.py --- ...buildmimetree.cpython-311-pytest-7.4.0.pyc | Bin 41182 -> 0 bytes .config/neomutt/buildmimetree.py | 574 ++++++++++++++++++ .config/neomutt/neomuttrc | 2 + 3 files changed, 576 insertions(+) delete mode 100644 .config/neomutt/__pycache__/buildmimetree.cpython-311-pytest-7.4.0.pyc create mode 100755 .config/neomutt/buildmimetree.py diff --git a/.config/neomutt/__pycache__/buildmimetree.cpython-311-pytest-7.4.0.pyc b/.config/neomutt/__pycache__/buildmimetree.cpython-311-pytest-7.4.0.pyc deleted file mode 100644 index 0df1cecc525c4aa3dbb5f3ebcf554a07430bd725..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41182 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 yyMqKy(FZ`jR_I)n@9byJMZcTAQ3~*Y0KO{%(0 source '$my_confdir/buildmimetree.py setup|'\ +# sourc e \$my_mdwn_postprocess_cmd_file\ +# " "Convert message into a modern MIME tree with inline images" +# +# (Yes, we need to call source twice, as mutt only starts to process output +# from a source command when the command exits, and since we need to react +# to the output, we need to be invoked again, using a $my_ variable to pass +# information) +# +# Requirements: +# - python3 +# - python3-markdown +# Optional: +# - pytest +# - Pynliner +# - Pygments, if installed, then syntax highlighting is enabled +# +# Latest version: +# https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py +# +# Copyright © 2023 martin f. krafft +# Released under the GPL-2+ licence, just like Mutt itself. +# + +import sys +import pathlib +import markdown +import tempfile +import argparse +from collections import namedtuple + + +def parse_cli_args(*args, **kwargs): + parser = argparse.ArgumentParser( + description=( + "NeoMutt helper to turn text/markdown email parts " + "into full-fledged MIME trees" + ) + ) + parser.epilog = ( + "Copyright © 2022 martin f. krafft .\n" + "Released under the MIT licence" + ) + + subp = parser.add_subparsers(help="Sub-command parsers", dest="mode") + parser_setup = subp.add_parser("setup", help="Setup phase") + parser_massage = subp.add_parser("massage", help="Massaging phase") + + parser_setup.add_argument( + "--debug-commands", + action="store_true", + help="Turn on debug logging of commands generated to stderr", + ) + + parser_setup.add_argument( + "--extension", + "-x", + metavar="EXTENSION", + dest="extensions", + nargs="?", + default=[], + action="append", + help="Markdown extension to add to the list of extensions use", + ) + + parser_massage.add_argument( + "--debug-commands", + action="store_true", + help="Turn on debug logging of commands generated to stderr", + ) + + parser_massage.add_argument( + "--debug-walk", + action="store_true", + help="Turn on debugging to stderr of the MIME tree walk", + ) + + parser_massage.add_argument( + "--extensions", + metavar="EXTENSIONS", + type=str, + default="", + help="Markdown extension to use (comma-separated list)", + ) + + parser_massage.add_argument( + "--write-commands-to", + metavar="PATH", + dest="cmdpath", + help="Temporary file path to write commands to", + ) + + parser_massage.add_argument( + "MAILDRAFT", + nargs="?", + help="If provided, the script is invoked as editor on the mail draft", + ) + + return parser.parse_args(*args, **kwargs) + + +# [ PARTS GENERATION ] ######################################################## + + +class Part( + namedtuple( + "Part", + ["type", "subtype", "path", "desc", "cid", "orig"], + defaults=[None, None, False], + ) +): + def __str__(self): + ret = f"<{self.type}/{self.subtype}>" + if self.cid: + ret = f"{ret} cid:{self.cid}" + if self.orig: + ret = f"{ret} ORIGINAL" + return ret + + +class Multipart( + namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None]) +): + def __str__(self): + return f" children={len(self.children)}" + + +def convert_markdown_to_html(maildraft, *, extensions=None): + draftpath = pathlib.Path(maildraft) + textpart = Part( + "text", "plain", draftpath, "Plain-text version", orig=True + ) + + with open(draftpath, "r", encoding="utf-8") as textmarkdown: + text = textmarkdown.read() + + mdwn = markdown.Markdown(extensions=extensions) + html = mdwn.convert(text) + + htmlpath = draftpath.with_suffix(".html") + htmlpart = Part("text", "html", htmlpath, "HTML version") + + with open( + htmlpath, "w", encoding="utf-8", errors="xmlcharrefreplace" + ) as texthtml: + texthtml.write(html) + + logopart = Part( + "image", + "png", + "/usr/share/doc/neomutt/logo/neomutt-256.png", + "Logo", + "neomutt-256.png", + ) + + return Multipart( + "relative", + [ + Multipart( + "alternative", + [textpart, htmlpart], + "Group of alternative content", + ), + logopart, + ], + "Group of related content", + ) + + +class MIMETreeDFWalker: + def __init__(self, *, visitor_fn=None, debug=False): + self._visitor_fn = visitor_fn + self._debug = debug + + def walk(self, root, *, visitor_fn=None): + """ + Recursive function to implement a depth-dirst walk of the MIME-tree + rooted at `root`. + """ + + if isinstance(root, list): + root = Multipart("mixed", children=root) + + self._walk( + root, + stack=[], + visitor_fn=visitor_fn or self._visitor_fn, + ) + + def _walk(self, node, *, stack, visitor_fn): + # Let's start by enumerating the parts at the current level. At the + # root level, stack will be the empty list, and we expect a multipart/* + # container at this level. Later, e.g. within a mutlipart/alternative + # container, the subtree will just be the alternative parts, while the + # top of the stack will be the multipart/alternative container, which + # we will process after the following loop. + + lead = f"{'| '*len(stack)}|-" + if isinstance(node, Multipart): + self.debugprint( + f"{lead}{node} parents={[s.subtype for s in stack]}" + ) + + # Depth-first, so push the current container onto the stack, + # then descend … + stack.append(node) + self.debugprint("| " * (len(stack) + 1)) + for child in node.children: + self._walk( + child, + stack=stack, + visitor_fn=visitor_fn, + ) + self.debugprint("| " * len(stack)) + assert stack.pop() == node + + else: + self.debugprint(f"{lead}{node}") + + if visitor_fn: + visitor_fn(node, stack, debugprint=self.debugprint) + + def debugprint(self, s, **kwargs): + if self._debug: + print(s, file=sys.stderr, **kwargs) + + +# [ RUN MODES ] ############################################################### + + +class MuttCommands: + """ + Stupid class to interface writing out Mutt commands. This is quite a hack + to deal with the fact that Mutt runs "push" commands in reverse order, so + all of a sudden, things become very complicated when mixing with "real" + commands. + + Hence we keep two sets of commands, and one set of pushes. Commands are + added to the first until a push is added, after which commands are added to + the second set of commands. + + On flush(), the first set is printed, followed by the pushes in reverse, + and then the second set is printed. All 3 sets are then cleared. + """ + + def __init__(self, out_f=sys.stdout, *, debug=False): + self._cmd1, self._push, self._cmd2 = [], [], [] + self._out_f = out_f + self._debug = debug + + def cmd(self, s): + self.debugprint(s) + if self._push: + self._cmd2.append(s) + else: + self._cmd1.append(s) + + def push(self, s): + s = s.replace('"', '"') + s = f'push "{s}"' + self.debugprint(s) + self._push.insert(0, s) + + def flush(self): + print( + "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f + ) + self._cmd1, self._push, self._cmd2 = [], [], [] + + def debugprint(self, s, **kwargs): + if self._debug: + print(s, file=sys.stderr, **kwargs) + + +def do_setup( + extensions=None, *, out_f=sys.stdout, temppath=None, debug_commands=False +): + extensions = extensions or [] + temppath = temppath or pathlib.Path( + tempfile.mkstemp(prefix="muttmdwn-")[1] + ) + cmds = MuttCommands(out_f, debug=debug_commands) + + editor = f"{sys.argv[0]} massage --write-commands-to {temppath}" + if extensions: + editor = f'{editor} --extensions {",".join(extensions)}' + + cmds.cmd('set my_editor="$editor"') + cmds.cmd('set my_edit_headers="$edit_headers"') + cmds.cmd(f'set editor="{editor}"') + cmds.cmd("unset edit_headers") + cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}") + cmds.push("") + cmds.flush() + + +def do_massage( + maildraft, + cmdpath, + *, + extensions=None, + converter=convert_markdown_to_html, + debug_commands=False, + debug_walk=False, +): + # Here's the big picture: we're being invoked as the editor on the email + # draft, and whatever commands we write to the file given as cmdpath will + # be run by the second source command in the macro definition. + + with open(cmdpath, "w") as cmd_f: + # Let's start by cleaning up what the setup did (see above), i.e. we + # restore the $editor and $edit_headers variables, and also unset the + # variable used to identify the command file we're currently writing + # to. + cmds = MuttCommands(cmd_f, debug=debug_commands) + cmds.cmd('set editor="$my_editor"') + cmds.cmd('set edit_headers="$my_edit_headers"') + cmds.cmd("unset my_editor") + cmds.cmd("unset my_edit_headers") + + # let's flush those commands, as there'll be a lot of pushes from now + # on, which need to be run in reverse order + cmds.flush() + + extensions = extensions.split(",") if extensions else [] + tree = converter(maildraft, extensions=extensions) + + mimetree = MIMETreeDFWalker(debug=args.debug_walk) + + def visitor_fn(item, stack, *, debugprint=None): + """ + Visitor function called for every node (part) of the MIME tree, + depth-first, and responsible for telling NeoMutt how to assemble + the tree. + """ + if isinstance(item, Part): + # We've hit a leaf-node, i.e. an alternative or a related part + # with actual content. + + # If the part is not an original part, i.e. doesn't already + # exist, we must first add it. + if not item.orig: + cmds.push(f"{item.path}") + cmds.push("") + if item.cid: + cmds.push( + f"\\Ca\\Ck{item.cid}" + ) + + # If the item (including the original) comes with a + # description, then we might just as well update the NeoMutt + # tree now: + if item.desc: + cmds.push(f"\\Ca\\Ck{item.desc}") + + # Finally, tag the entry that we just processed, so that when + # we're done at this level, as we walk up the stack, the items + # to be grouped will already be tagged and ready. + cmds.push("") + + elif isinstance(item, Multipart): + # This node has children, but we already visited them (see + # above), and so they have been tagged in NeoMutt's compose + # window. Now it's just a matter of telling NeoMutt to do the + # appropriate grouping: + if item.subtype == "alternative": + cmds.push("") + elif item.subtype == "relative": + cmds.push("") + elif item.subtype == "multilingual": + cmds.push("") + + # Again, if there is a description, we might just as well: + if item.desc: + cmds.push(f"\\Ca\\Ck{item.desc}") + + # Finally, if we're at non-root level, tag the new container, + # as it might itself be part of a container, to be processed + # one level up: + if stack: + cmds.push("") + + else: + # We should never get here + assert not "is valid part" + + # ----------------- + # End of visitor_fn + + # Let's walk the tree and visit every node with our fancy visitor + # function + mimetree.walk(tree, visitor_fn=visitor_fn) + + # Finally, cleanup. Since we're responsible for removing the temporary + # file, how's this for a little hack? + cmds.cmd(f"source 'rm -f {args.cmdpath}|'") + cmds.cmd("unset my_mdwn_postprocess_cmd_file") + cmds.flush() + + +# [ CLI ENTRY ] ############################################################### + +if __name__ == "__main__": + args = parse_cli_args() + + if args.mode == "setup": + do_setup(args.extensions, debug_commands=args.debug_commands) + + elif args.mode == "massage": + do_massage( + args.MAILDRAFT, + args.cmdpath, + extensions=args.extensions, + debug_commands=args.debug_commands, + debug_walk=args.debug_walk, + ) + + +# [ TESTS ] ################################################################### + +try: + import pytest + + class Tests: + @pytest.fixture + def const1(self): + return "CONSTANT STRING 1" + + @pytest.fixture + def const2(self): + return "CONSTANT STRING 2" + + # NOTE: tests using the capsys fixture must specify sys.stdout to the + # functions they call, else old stdout is used and not captured + + def test_MuttCommands_cmd(self, const1, const2, capsys): + "Assert order of commands" + cmds = MuttCommands(out_f=sys.stdout) + cmds.cmd(const1) + cmds.cmd(const2) + cmds.flush() + captured = capsys.readouterr() + assert captured.out == "\n".join((const1, const2, "")) + + def test_MuttCommands_push(self, const1, const2, capsys): + "Assert reverse order of pushes" + cmds = MuttCommands(out_f=sys.stdout) + cmds.push(const1) + cmds.push(const2) + cmds.flush() + captured = capsys.readouterr() + assert ( + captured.out + == ('"\npush "'.join(("", const2, const1, "")))[2:-6] + ) + + def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys): + "Assert reverse order of pushes" + cmds = MuttCommands(out_f=sys.stdout) + lines = ["000", "001", "010", "011", "100", "101", "110", "111"] + for i in range(2): + cmds.cmd(lines[4 * i + 0]) + cmds.cmd(lines[4 * i + 1]) + cmds.push(lines[4 * i + 2]) + cmds.push(lines[4 * i + 3]) + cmds.flush() + + captured = capsys.readouterr() + lines_out = captured.out.splitlines() + assert lines[0] in lines_out[0] + assert lines[1] in lines_out[1] + assert lines[7] in lines_out[2] + assert lines[6] in lines_out[3] + assert lines[3] in lines_out[4] + assert lines[2] in lines_out[5] + assert lines[4] in lines_out[6] + assert lines[5] in lines_out[7] + + @pytest.fixture + def basic_mime_tree(self): + return Multipart( + "related", + children=[ + Multipart( + "alternative", + children=[ + Part("text", "plain", "part.txt", desc="Plain"), + Part("text", "html", "part.html", desc="HTML"), + ], + desc="Alternative", + ), + Part( + "text", "png", "logo.png", cid="logo.png", desc="Logo" + ), + ], + desc="Related", + ) + + def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree): + mimetree = MIMETreeDFWalker() + + items = [] + + def visitor_fn(item, stack, debugprint): + items.append((item, len(stack))) + + mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn) + assert len(items) == 5 + assert items[0][0].subtype == "plain" + assert items[0][1] == 2 + assert items[1][0].subtype == "html" + assert items[1][1] == 2 + assert items[2][0].subtype == "alternative" + assert items[2][1] == 1 + assert items[3][0].subtype == "png" + assert items[3][1] == 1 + assert items[4][0].subtype == "related" + assert items[4][1] == 0 + + def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree): + mimetree = MIMETreeDFWalker() + + items = [] + + def visitor_fn(item, stack, debugprint): + items.append(item) + + mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn) + assert items[-1].subtype == "mixed" + + def test_MIMETreeDFWalker_visitor_in_constructor( + self, basic_mime_tree + ): + items = [] + + def visitor_fn(item, stack, debugprint): + items.append(item) + + mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn) + mimetree.walk(basic_mime_tree) + assert len(items) == 5 + + def test_do_setup_no_extensions(self, const1, capsys): + "Assert basics about the setup command output" + do_setup(temppath=const1, out_f=sys.stdout) + captout = capsys.readouterr() + lines = captout.out.splitlines() + assert lines[2].endswith(f'{const1}"') + assert lines[4].endswith(const1) + assert "first-entry" in lines[-1] + assert "edit-file" in lines[-1] + + def test_do_setup_extensions(self, const1, const2, capsys): + "Assert that extensions are passed to editor" + do_setup( + temppath=const1, extensions=[const2, const1], out_f=sys.stdout + ) + captout = capsys.readouterr() + lines = captout.out.splitlines() + # assert comma-separated list of extensions passed + assert lines[2].endswith(f'{const2},{const1}"') + assert lines[4].endswith(const1) + +except ImportError: + pass diff --git a/.config/neomutt/neomuttrc b/.config/neomutt/neomuttrc index 3c02cde..5b29048 100644 --- a/.config/neomutt/neomuttrc +++ b/.config/neomutt/neomuttrc @@ -61,3 +61,5 @@ source "test -f $alias_file && cat $alias_file 2>/dev/null || echo unset alias_f #set index_format="%4C %?GU?%GU& ?%?GR?%GR& ?%?GI?%GI& ? %-10@date@ %-15.15F %4c%?M?/[%M]? %?H?[%H] ?%s" # #set pager_format="<%a> %* %J (%P)" + +macro compose B " source '$my_confdir/buildmimetree.py setup|' source \$my_mdwn_postprocess_cmd_file" "Convert message into a modern MIME tree with inline images" -- 2.39.5