From eb8f4574bf5a03bda74fe6a27dd3a6f81271a1dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=84min=20Baumeler?= Date: Thu, 12 Mar 2026 14:52:29 +0100 Subject: [PATCH] feat: html rendering --- src/awk/lastmessageinthread.awk | 49 ------------------------ src/awk/messageheader.awk | 13 +++++++ src/awk/messageparts.awk | 12 ++++++ src/awk/partnr.awk | 14 +++++++ src/main.sh | 68 ++++++++++++++++----------------- src/sh/awk.sh | 28 ++++++++++---- src/sh/lists.sh | 32 ++++++++++++++++ src/sh/notmuch.sh | 33 ++++++++++++++++ src/sh/preview.sh | 21 ++++++++++ src/sh/tools.sh | 14 +++++-- 10 files changed, 191 insertions(+), 93 deletions(-) delete mode 100644 src/awk/lastmessageinthread.awk create mode 100644 src/awk/messageheader.awk create mode 100644 src/awk/messageparts.awk create mode 100644 src/awk/partnr.awk create mode 100644 src/sh/lists.sh create mode 100644 src/sh/notmuch.sh create mode 100644 src/sh/preview.sh diff --git a/src/awk/lastmessageinthread.awk b/src/awk/lastmessageinthread.awk deleted file mode 100644 index bfbf420..0000000 --- a/src/awk/lastmessageinthread.awk +++ /dev/null @@ -1,49 +0,0 @@ -BEGIN { - RS = "\x0c" - FS = "\n" -} - -/^message}/ { - mheader = header - mpart = part - mparts = parts - level = 0 - multipart = "" - header = "" - part = "" - parts = "" -} -/^header{/ { - header = $0 -} -/^(part|attachment)}/ { - level-- -} -/^(part|attachment){/ { - parts = sprintf("%s\n%"(2*level)"s%s", parts, "", substr($1, index($1, "ID"))) - level++ -} -# /^part{ ID: [[:digit:]]+, Content-type: multipart\/alternative/ { -# multipart = "alternative" -# } -# /^part{ ID: [[:digit:]]+, Content-type: multipart\/mixed/ { -# multipart = "mixed" -# } -/^part{ ID: [[:digit:]]+, Content-type: text\/plain/ { - part = $0 -} - -END { - split(mheader, a, "\n") - delete a[1] - # delete a[2] - for (line in a) { - print a[line] - } - split(mpart, a, "\n") - delete a[1] - for (line in a) { - print a[line] - } - print "Parts:"mparts -} diff --git a/src/awk/messageheader.awk b/src/awk/messageheader.awk new file mode 100644 index 0000000..c3bb286 --- /dev/null +++ b/src/awk/messageheader.awk @@ -0,0 +1,13 @@ +BEGIN { + RS = "\x0c" + FS = "\n" +} + +/^header{/ { + split($0, a, "\n") + delete a[1] + delete a[2] + for (line in a) + print a[line] + exit +} diff --git a/src/awk/messageparts.awk b/src/awk/messageparts.awk new file mode 100644 index 0000000..6d2a884 --- /dev/null +++ b/src/awk/messageparts.awk @@ -0,0 +1,12 @@ +BEGIN { + RS = "\x0c" + FS = "\n" +} + +/^(part|attachment)}/ { + level-- +} +/^(part|attachment){/ { + printf "%"(2*level)"s%s\n", "", substr($1, index($1, "ID")) + level++ +} diff --git a/src/awk/partnr.awk b/src/awk/partnr.awk new file mode 100644 index 0000000..626c4bb --- /dev/null +++ b/src/awk/partnr.awk @@ -0,0 +1,14 @@ +# Return the part number of the first text/plain part, or, if the variable +# `html` is set, of the first text/html part. +BEGIN { + RS = "\x0c" + FS = "\n" +} + +/^part{.*Content-type: text\// { + match($1, "ID: ([[:digit:]]+).* Content-type: text/([[:alpha:]]+)", a) + if ((html && a[2] == "html") || (!html && a[2] == "plain")) { + print a[1] + exit + } +} diff --git a/src/main.sh b/src/main.sh index a35490c..2d9be70 100755 --- a/src/main.sh +++ b/src/main.sh @@ -17,47 +17,43 @@ set -eu # tools . "sh/tools.sh" -# history +# query history . "sh/history.sh" -__notmuch_search() { - $NOTMUCH search "$*" \ - | sed "s/^thread:\([[:xdigit:]]\+\)\s\+\([^[]\+\)\s\+\[\([^]]\+\)\]\([^;]*\); \(.*\) (\([^(]*\))$/${COLDATE}\2${COLRESET}\t${COLCNTS}\3${COLRESET}\t${COLFROM}\4${COLRESET}\t${COLSUBJ}\5${COLRESET}\t${COLTAGS}\6${COLRESET}\t\1/" \ - | column -s "$(printf '\t')" -t -l 3 -R 1,2 -} +# notmuch functions +. "sh/notmuch.sh" -__notmuch_thread() { - $NOTMUCH show thread:"$1" \ - | awk "$AWK_THREADOVERVIEW" \ - | sed "s/^\(\S*\)\t\([^\t]*\)\t\(.*\) (\([^()]*\)) (\([^()]*\))$/${COLFROM}\3${COLRESET}\t${COLSUBJ}\2${COLRESET}\t${COLDATE}\4${COLRESET}\t${COLTAGS}\5${COLRESET}\t\1/" -} - -__notmuch_list_tags() { - $NOTMUCH search --output=tags tag:/./ -} - -__notmuch_list_address() { - $NOTMUCH address '*' -} +# list generators +. "sh/lists.sh" +# preview functions +. "sh/preview.sh" if [ "${1:-}" = "--preview-thread" ]; then shift - thread="$1" - $NOTMUCH show thread:$thread \ - | awk "$AWK_LASTMESSAGEINTHREAD" \ - | tail -n +2 \ - | $CAT + threadid="$1" + preview_message "thread" "$threadid" + exit +fi + +if [ "${1:-}" = "--preview-html-thread" ]; then + shift + threadid="$1" + preview_message "thread" "$threadid" "html" exit fi if [ "${1:-}" = "--preview-message" ]; then shift messageid="$1" - $NOTMUCH show id:$messageid \ - | awk "$AWK_LASTMESSAGEINTHREAD" \ - | tail -n +2 \ - | $CAT + preview_message "id" "$messageid" + exit +fi + +if [ "${1:-}" = "--preview-html-message" ]; then + shift + messageid="$1" + preview_message "id" "$messageid" "html" exit fi @@ -76,7 +72,7 @@ FZF_DEFAULT_THREAD_LINE="{1} {3} ({4})" if [ "${1:-}" = "--show-thread" ]; then shift thread="$1" - res=$(__notmuch_thread "$thread" | $FZF \ + res=$(list_messages_in_thread "$thread" | $FZF \ --raw \ --reverse \ --multi \ @@ -91,6 +87,8 @@ if [ "${1:-}" = "--show-thread" ]; then --bind="ctrl-h,backward-eof:abort" \ --bind="alt-/:change-preview-window($FZF_ALTERNATE_PREVIEW_WINDOW|$FZF_DEFAULT_PREVIEW_WINDOW)" \ --bind="alt-v:execute:$0 --view-source {5}" \ + --bind="alt-h:change-preview:$0 --preview-html-message {5}" \ + --bind="focus:change-preview:$0 --preview-message {5}" \ --preview="$0 --preview-message {5}" \ --preview-window="$FZF_DEFAULT_PREVIEW_WINDOW" || true) exit @@ -108,7 +106,7 @@ CMD_EDIT_QUERY="edit-query" while true; do nmquery="${nmquery:-tag:inbox}" [ "$(tail -1 "$NMFHIST")" = "$nmquery" ] || echo "$nmquery" >> "$NMFHIST" - cmd=$(__notmuch_search "$nmquery" | $FZF \ + cmd=$(list_threads "$nmquery" | $FZF \ --header="Query: $nmquery" \ --tiebreak=index \ --reverse \ @@ -127,12 +125,14 @@ while true; do --bind='page-up:preview-half-page-up,page-down:preview-half-page-down' \ --bind="enter,ctrl-l:execute:$0 --show-thread {4}" \ --bind="alt-/:change-preview-window($FZF_ALTERNATE_PREVIEW_WINDOW|$FZF_DEFAULT_PREVIEW_WINDOW)" \ + --bind="alt-h:change-preview:$0 --preview-html-thread {4}" \ + --bind="focus:change-preview:$0 --preview-thread {4}" \ --preview="$0 --preview-thread {4}" \ --preview-window="$FZF_DEFAULT_PREVIEW_WINDOW" | head -1 || true) [ -n "$cmd" ] || exit 0 case "$cmd" in $CMD_SEARCH_TAG*) - tag=$(__notmuch_list_tags | $FZF \ + tag=$(list_tags | $FZF \ --color="border:$ANSICOLORTAGS" \ --color="label:$ANSICOLORTAGS" \ --tac \ @@ -155,7 +155,7 @@ while true; do field='to' ;; esac - address=$(__notmuch_list_address | $FZF \ + address=$(list_addresses | $FZF \ --color="border:$ANSICOLORFROM" \ --color="label:$ANSICOLORFROM" \ --tiebreak=index \ @@ -175,7 +175,7 @@ while true; do esac ;; $CMD_EDIT_QUERY*) - nmquery=$($FZF \ + nmquery=$(list_query_history | $FZF \ --color="border:$ANSICOLORSUBJ" \ --color="label:$ANSICOLORSUBJ" \ --tac \ @@ -187,7 +187,7 @@ while true; do --prompt="Query: " \ --bind='focus:replace-query' \ --border=double \ - --border-label=' Query history ' < "$NMFHIST" | head -1 || true) + --border-label=' Query history ' | head -1 || true) ;; *) ;; diff --git a/src/sh/awk.sh b/src/sh/awk.sh index 2d3b43f..ff08e69 100644 --- a/src/sh/awk.sh +++ b/src/sh/awk.sh @@ -1,11 +1,4 @@ if [ ! "${AWK_LOADED:-}" ]; then - AWK_LASTMESSAGEINTHREAD=$( - cat <<'EOF' -@@include awk/lastmessageinthread.awk -EOF - ) - export AWK_LASTMESSAGEINTHREAD - AWK_THREADOVERVIEW=$( cat <<'EOF' @@include awk/threadoverview.awk @@ -13,5 +6,26 @@ EOF ) export AWK_THREADOVERVIEW + AWK_MESSAGEHEADER=$( + cat <<'EOF' +@@include awk/messageheader.awk +EOF + ) + export AWK_MESSAGEHEADER + + AWK_MESSAGEPARTS=$( + cat <<'EOF' +@@include awk/messageparts.awk +EOF + ) + export AWK_MESSAGEPARTS + + AWK_PARTNR=$( + cat <<'EOF' +@@include awk/partnr.awk +EOF + ) + export AWK_PARTNR + export AWK_LOADED=1 fi diff --git a/src/sh/lists.sh b/src/sh/lists.sh new file mode 100644 index 0000000..57bcb64 --- /dev/null +++ b/src/sh/lists.sh @@ -0,0 +1,32 @@ +# These functions generate the lists that are fed into fzf + +# List the messages within a thread +# @argument $1: thread id +list_messages_in_thread() { + $NOTMUCH show thread:"$1" \ + | awk "$AWK_THREADOVERVIEW" \ + | sed "s/^\(\S*\)\t\([^\t]*\)\t\(.*\) (\([^()]*\)) (\([^()]*\))$/${COLFROM}\3${COLRESET}\t${COLSUBJ}\2${COLRESET}\t${COLDATE}\4${COLRESET}\t${COLTAGS}\5${COLRESET}\t\1/" +} + +# List all threads that match a query +# @argument $1: query +list_threads() { + $NOTMUCH search "$1" \ + | sed "s/^thread:\([[:xdigit:]]\+\)\s\+\([^[]\+\)\s\+\[\([^]]\+\)\]\([^;]*\); \(.*\) (\([^(]*\))$/${COLDATE}\2${COLRESET}\t${COLCNTS}\3${COLRESET}\t${COLFROM}\4${COLRESET}\t${COLSUBJ}\5${COLRESET}\t${COLTAGS}\6${COLRESET}\t\1/" \ + | column -s "$(printf '\t')" -t -l 3 -R 1,2 +} + +# List all available tags +list_tags() { + $NOTMUCH search --output=tags tag:/./ +} + +# List all email addresses +list_addresses() { + $NOTMUCH address '*' +} + +# List query history +list_query_history() { + cat "$NMFHIST" +} diff --git a/src/sh/notmuch.sh b/src/sh/notmuch.sh new file mode 100644 index 0000000..55568af --- /dev/null +++ b/src/sh/notmuch.sh @@ -0,0 +1,33 @@ +# Wrapper around notmuch + +# Print the message id of the last message within a thread. +# @argument $1: thread id +nm_last_message_in_thread() { + $NOTMUCH search --output=messages --offset=-1 thread:"$1" | sed 's/^...//' +} + +# Print the header of a message (with trailing empty line) +# @argument $1: message id +nm_message_header() { + $NOTMUCH show --body=false id:"$1" | awk "$AWK_MESSAGEHEADER" +} + +# Print the message parts (with indents) +# @argument $1: message id +nm_message_parts() { + $NOTMUCH show --body=true id:"$1" | awk "$AWK_MESSAGEPARTS" +} + +# Print the message part number for text/plain or text/html +# @argument $1: message id +# @argument $2: if set, then the html part number will be printed +nm_message_part_nr() { + $NOTMUCH show --body=true id:"$1" | awk -v html="${2:-}" "$AWK_PARTNR" +} + +# Print part of message +# @argument $1: message id +# @argument $2: part number +nm_message_get_part() { + $NOTMUCH show --part="$2" id:"$1" +} diff --git a/src/sh/preview.sh b/src/sh/preview.sh new file mode 100644 index 0000000..8fd29ee --- /dev/null +++ b/src/sh/preview.sh @@ -0,0 +1,21 @@ +# Preview functions + +# Preview email +# If a thread identifier is given, then the last message in the thread is +# shown. If the HTML flag is set, then the HTML message is parsed using pandoc. +# This functions reverts to the HTML method if no HTML flag is set yet no +# text/plain part is found. +# +# @argument $1: "id" or "thread" +# @argument $2: message id or thread id +# @argument #3: if set, then the HTML messages will be taken +preview_message() { + [ "$1" = "id" ] && messageid="$2" || messageid="$(nm_last_message_in_thread "$2")" + parts="$(nm_message_parts "$messageid")" + html="${3:-}" + nr="$(nm_message_part_nr "$messageid" "$html")" + [ ! "$nr" ] && [ ! "$html" ] && html="html" && nr="$(nm_message_part_nr "$messageid" "html")" + [ "$html" ] && parser="$PANDOC" || parser="cat" + nm_message_header "$messageid" | $CATEMAIL + nm_message_get_part "$messageid" "$nr" | $parser | $CATMD +} diff --git a/src/sh/tools.sh b/src/sh/tools.sh index d4847a5..f7da87b 100644 --- a/src/sh/tools.sh +++ b/src/sh/tools.sh @@ -20,9 +20,17 @@ if [ ! "${TOOLS_LOADED:-}" ]; then elif command -v "batcat" >/dev/null; then CAT="batcat" fi - CAT=${CAT:+$CAT -l email --style=plain --color=always --wrap} - CAT=${CAT:-cat} - export CAT + CATEMAIL=${CAT:+$CAT -l email --style=plain --color=always} + CATMD=${CAT:+$CAT -l markdown --style=plain --color=always} + CATEMAIL=${CATEMAIL:-cat} + CATEMD=${CATMD:-cat} + export CATEMAIL CATMD + + if command -v "pandoc" >/dev/null; then + PANDOC="pandoc --from html --to markdown_strict-raw_html" + fi + PANDOC=${PANDOC:-cat} + export PANDOC export TOOLS_LOADED=1 fi