parser.add_argument(
"--related-to-html-only",
action="store_true",
- help="Make related content be sibling to HTML parts only"
+ help="Make related content be sibling to HTML parts only",
+ )
+
+ def positive_integer(value):
+ try:
+ if int(value) > 0:
+ return int(value)
+
+ except ValueError:
+ pass
+
+ raise ValueError(f"Must be a positive integer")
+
+ parser.add_argument(
+ "--max-number-other-attachments",
+ type=positive_integer,
+ help="Make related content be sibling to HTML parts only",
)
parser.add_argument(
self._walk(
root,
ancestry=[],
+ descendents=[],
visitor_fn=visitor_fn or self._visitor_fn,
)
- def _walk(self, node, *, ancestry, visitor_fn):
+ def _walk(self, node, *, ancestry, descendents, visitor_fn):
# Let's start by enumerating the parts at the current level. At the
# root level, ancestry will be the empty list, and we expect a
# multipart/* container at this level. Later, e.g. within a
self._walk(
child,
ancestry=ancestry,
+ descendents=descendents,
visitor_fn=visitor_fn,
)
assert ancestry.pop() == node
+ sibling_descendents = descendents
+ descendents.extend(node.children)
else:
self.debugprint(f"{lead}├{node}")
+ sibling_descendents = descendents
if False and ancestry:
self.debugprint(lead[:-1] + " │")
if visitor_fn:
- visitor_fn(node, ancestry, debugprint=self.debugprint)
+ visitor_fn(
+ node, ancestry, sibling_descendents, debugprint=self.debugprint
+ )
def debugprint(self, s, **kwargs):
if self._debug:
converter=convert_markdown_to_html,
related_to_html_only=True,
only_build=False,
+ max_other_attachments=20,
tempdir=None,
debug_commands=False,
debug_walk=False,
state = dict(pos=1, tags={}, parts=1)
- def visitor_fn(item, ancestry, *, debugprint=None):
+ def visitor_fn(item, ancestry, descendents, *, debugprint=None):
"""
Visitor function called for every node (part) of the MIME tree,
depth-first, and responsible for telling NeoMutt how to assemble
if item.cid:
cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
+ # Now for the biggest hack in this script, which is to handle
+ # attachments, such as PDFs, that aren't related or alternatives.
+ # The problem is that when we add an inline image, it always gets
+ # appended to the list, i.e. inserted *after* other attachments.
+ # Since we don't know the number of attachments, we also cannot
+ # infer the postition of the new attachment. Therefore, we bubble
+ # it all the way to the top, only to then move it down again:
+ if state["pos"] > 1: # skip for the first part
+ for i in range(max_other_attachments):
+ # could use any number here, but has to be larger than the
+ # number of possible attachments. The performance
+ # difference of using a high number is negligible.
+ # Bubble up the new part
+ cmds.push(f"<move-up>")
+
+ # As we push the part to the right position in the list (i.e.
+ # the last of the subset of attachments this script added), we
+ # must handle the situation that subtrees are skipped by
+ # NeoMutt. Hence, the actual number of positions to move down
+ # is decremented by the number of descendents so far
+ # encountered.
+ for i in range(1, state["pos"] - len(descendents)):
+ cmds.push(f"<move-down>")
+
elif isinstance(item, Multipart):
# This node has children, but we already visited them (see
# above). The tags dictionary of State should contain a list of
# their positions in the NeoMutt compose window, so iterate those
# and tag the parts there:
+ n_tags = len(state["tags"][item])
for tag in state["tags"][item]:
cmds.push(f"<jump>{tag}<enter><tag-entry>")
f"Handling of multipart/{item.subtype} is not implemented"
)
- state["pos"] -= len(state["tags"][item]) - 1
+ state["pos"] -= n_tags - 1
state["parts"] += 1
- del state["tags"][item]
else:
# We should never get here
lead = "│ " * (len(ancestry) + 1) + "* "
debugprint(
f"{lead}ancestry={[a.subtype for a in ancestry]}\n"
+ f"{lead}descendents={[d.subtype for d in descendents]}\n"
f"{lead}children_positions={state['tags'][ancestry[-1]]}\n"
f"{lead}pos={state['pos']}, parts={state['parts']}"
)
extensions=args.extensions,
cssfile=args.css_file,
related_to_html_only=args.related_to_html_only,
+ max_other_attachments=args.max_number_other_attachments,
only_build=args.only_build,
tempdir=args.tempdir,
debug_commands=args.debug_commands,
items = []
- def visitor_fn(item, ancestry, debugprint):
- items.append((item, len(ancestry)))
+ def visitor_fn(item, ancestry, descendents, debugprint):
+ items.append((item, len(ancestry), len(descendents)))
mimetree.walk(
mime_tree_related_to_alternative, visitor_fn=visitor_fn
assert len(items) == 5
assert items[0][0].subtype == "plain"
assert items[0][1] == 2
+ assert items[0][2] == 0
assert items[1][0].subtype == "html"
assert items[1][1] == 2
+ assert items[1][2] == 0
assert items[2][0].subtype == "alternative"
assert items[2][1] == 1
+ assert items[2][2] == 2
assert items[3][0].subtype == "png"
assert items[3][1] == 1
+ assert items[3][2] == 2
assert items[4][0].subtype == "relative"
assert items[4][1] == 0
+ assert items[4][2] == 4
def test_MIMETreeDFWalker_list_to_mixed(self, const1):
mimetree = MIMETreeDFWalker()
items = []
- def visitor_fn(item, ancestry, debugprint):
+ def visitor_fn(item, ancestry, descendents, debugprint):
items.append(item)
p = Part("text", "plain", const1)
):
items = []
- def visitor_fn(item, ancestry, debugprint):
+ def visitor_fn(item, ancestry, descendents, debugprint):
items.append(item)
mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
):
return mime_tree_related_to_alternative
+ max_attachments = 5
do_massage(
draft_f=string_io,
draftpath=const1,
cmd_f=sys.stdout,
+ max_other_attachments=max_attachments,
converter=converter,
)
assert "Plain" in lines.pop()
assert "part.html" in lines.pop()
assert "toggle-unlink" in lines.pop()
+ for i in range(max_attachments):
+ assert "move-up" in lines.pop()
+ assert "move-down" in lines.pop()
assert "HTML" in lines.pop()
assert "jump>1" in lines.pop()
assert "jump>2" in lines.pop()
assert "logo.png" in lines.pop()
assert "toggle-unlink" in lines.pop()
assert "content-id" in lines.pop()
+ for i in range(max_attachments):
+ assert "move-up" in lines.pop()
+ assert "move-down" in lines.pop()
assert "Logo" in lines.pop()
assert "jump>1" in lines.pop()
assert "jump>4" in lines.pop()
assert "Plain" in lines.pop()
assert "part.html" in lines.pop()
assert "toggle-unlink" in lines.pop()
+ assert "move-up" in lines.pop()
+ while True:
+ top = lines.pop()
+ if "move-up" not in top:
+ break
+ assert "move-down" in top
assert "HTML" in lines.pop()
assert "logo.png" in lines.pop()
assert "toggle-unlink" in lines.pop()
assert "content-id" in lines.pop()
+ assert "move-up" in lines.pop()
+ while True:
+ top = lines.pop()
+ if "move-up" not in top:
+ break
+ assert "move-down" in top
+ assert "move-down" in lines.pop()
assert "Logo" in lines.pop()
assert "jump>2" in lines.pop()
assert "jump>3" in lines.pop()
assert "send-message" in lines.pop()
assert len(lines) == 0
+ def test_mime_tree_nested_trees_does_not_break_positioning(
+ self, string_io, const1, capsys
+ ):
+ def converter(
+ drafttext,
+ draftpath,
+ cssfile,
+ related_to_html_only,
+ extensions,
+ tempdir,
+ ):
+ return Multipart(
+ "relative",
+ children=[
+ Multipart(
+ "alternative",
+ children=[
+ Part(
+ "text",
+ "plain",
+ "part.txt",
+ desc="Plain",
+ orig=True,
+ ),
+ Multipart(
+ "alternative",
+ children=[
+ Part(
+ "text",
+ "plain",
+ "part.txt",
+ desc="Nested plain",
+ ),
+ Part(
+ "text",
+ "html",
+ "part.html",
+ desc="Nested HTML",
+ ),
+ ],
+ desc="Nested alternative",
+ ),
+ ],
+ desc="Alternative",
+ ),
+ Part(
+ "text",
+ "png",
+ "logo.png",
+ cid="logo.png",
+ desc="Logo",
+ ),
+ ],
+ desc="Related",
+ )
+
+ do_massage(
+ draft_f=string_io,
+ draftpath=const1,
+ cmd_f=sys.stdout,
+ converter=converter,
+ )
+
+ captured = capsys.readouterr()
+ lines = captured.out.splitlines()
+ while not "logo.png" in lines.pop():
+ pass
+ lines.pop()
+ assert "content-id" in lines.pop()
+ assert "move-up" in lines.pop()
+ while True:
+ top = lines.pop()
+ if "move-up" not in top:
+ break
+ assert "move-down" in top
+ # Due to the nested trees, the number of descendents of the sibling
+ # actually needs to be considered, not just the nieces. So to move
+ # from position 1 to position 6, it only needs one <move-down>
+ # because that jumps over the entire sibling tree. Thus what
+ # follows next must not be another <move-down>
+ assert "Logo" in lines.pop()
+
except ImportError:
pass