#!/bin/sh #――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― # Name: fedi2html.sh # Desc: # Reqs: # Date: #――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― POST_TEMPLATE='
$ACCOUNT_NAME $ACCOUNT_ID
$POST_CONTENT
$POST_ATTACHMENTS
$POST_RESPONSES
' ATTACH_TEMPLATE='
$ATTACH_NAME $ATTACH_DESC
' ATTACH_IMAGE_TEMPLATE='
$ATTACH_DESC
$ATTACH_NAME
$(if test $(echo "$ATTACH_DESC" | wc -c) -gt 52; then echo $ATTACH_DESC | head -c 53 | sed 's%[[:space:]]*$%%' echo … else echo $ATTACH_DESC fi)
' EMOJI_TEMPLATE='$EMOJI_SHORTCODE' # Given a note’s JSON, render it as HTML. # The most important part of the script! # render_post $post_data $context_data $tree_level render_post() { local post_data="$1" local responses_data="$2" local POST_TREE_LEVEL="$3" local ACCOUNT_URL="$(echo "$post_data" | jq -r .account.url)" local ACCOUNT_ID="$(echo "$post_data" | jq -r .account.fqn)" local ACCOUNT_NAME="$(echo "$post_data" | jq -r .account.display_name | replace_emojis "$(echo "$post_data" | jq -r '.account')")" local ACCOUNT_AVATAR="$(echo "$post_data" | jq -r .account.avatar)" local POST_URL="$(echo "$post_data" | jq -r .url)" local POST_DATE="$(echo "$post_data" | jq -r .created_at)" local POST_CONTENT="$(echo "$post_data" | jq -r .content | replace_emojis "$post_data")" local POST_ATTACHMENTS="$(media_attachments "$post_data")" if test -z "$NO_RESPONSES"; then local POST_RESPONSES="$(render_responses "$post_data" "$responses_data" "$POST_TREE_LEVEL")" fi env_subst "$POST_TEMPLATE" } # Render a post’s responses one-by-one and recursively. # Each branch of the response tree will be rendered completely before proceeding # to the next. # render_responses $post_data $context_data $tree_level render_responses() { local post_data="$1" local responses_data="$2" local level="$3" if test -z "$level"; then level=0; fi local id="$(echo "$post_data" | jq -r '.id')" local responses="$(echo "$responses_data" | grep "in_reply_to_id.*$id")" local IFS=" " for response in $responses; do render_post "$response" "$responses_data" "$(expr "$level" + 1)" done } # Accepts a string over stdin; it will replace all emoji shortcodes along stdin # input with appropriate HTML, based on $EMOJI_TEMPLATE, and based on # a post’s JSON. # echo ":blobcat:" | replace_emojis $post_data replace_emojis() { local post_data="$1" local emojis="$(echo "$post_data" | jq -r '.emojis[]|(.url + "\t" + .shortcode)')" local temp="$(mktemp)" local IFS=" " cat > "$temp" for line in $emojis; do local EMOJI_URL="$(echo "$line" | awk -F'\t' '{print $1}')" local EMOJI_SHORTCODE="$(echo "$line" | awk -F'\t' '{print $2}')" local value="$(env_subst "$EMOJI_TEMPLATE")" sed -i "s%:${EMOJI_SHORTCODE}:%${value}%g" "$temp" done cat "$temp" rm "$temp" } # Given a post’s JSON data, return the appropriate HTML corresponding to its # media attachments, if any. Will return an empty string if none. # media_attachments $post_data media_attachments() { local post_data="$1" local attachments="$(echo "$post_data" | jq -r '.media_attachments[]|(.type + "\t" + .url + "\t" + .description + "\t" + .preview_url)')" local IFS=" " for line in $attachments; do local ATTACH_TYPE="$(echo "$line" | awk -F'\t' '{print $1}')" local ATTACH_URL="$(echo "$line" | awk -F'\t' '{print $2}')" local ATTACH_DESC="$(echo "$line" | awk -F'\t' '{print $3}')" local ATTACH_PREVIEW="$(echo "$line" | awk -F'\t' '{print $4}')" local ATTACH_NAME="$(basename "$ATTACH_URL")" if test "$ATTACH_TYPE" = "image"; then env_subst "$ATTACH_IMAGE_TEMPLATE" else env_subst "$ATTACH_TEMPLATE" fi done } # Pass a post’s context JSON along stdin; out comes the response_data in JSON. # fetch_post_context $url | context_to_responses context_to_responses() { jq '.descendants' \ | jq 'sort_by(.created_at)' \ | maybe_jq_reverse \ | jq -cr '.[]' } # Make a request to the /api/v1/statuses/:id/$request API endpoint. # statuses_api_request $post_url $request statuses_api_request() { local post_url="$1" local api_request="$2" if test -n "$api_request"; then api_request="/$api_request" fi local id="$(url_id "$url")" local server="$(url_server "$url")" curl --location --header 'Accept: application/json,application/activity+json' \ "$server/api/v1/statuses/${id}${api_request}" } # Require the context-JSON of a post, by URL. # fetch_post_context $url fetch_post_context() { local url="$1" statuses_api_request "$url" "context" } # Given a post URL, request its JSON. # fetch_post $url fetch_post() { local url="$1" statuses_api_request "$url" } # Return the ID of a post, based on its URL. # url_id $url url_id() { local url="$1" # Pleroma-style URLs: https://jam.xwx.moe/notice/Ac6PIZAP0ZzkMTYBBg # Mastodon-style URLs: https://esperanto.masto.host/@minjo/111461250815264185 if echo "$url" | grep "/notice/" > /dev/null; then echo "$url" \ | sed 's%.*/notice/%%' else echo "$url" \ | sed 's%.*/@[[:alnum:]]*/%%' fi } # Return the server (including protocol) of a post, based on its URL. # url_server $url url_server() { local url="$1" local protocol="$(echo "$url" | grep --only-matching '[[:alnum:]]*://')" printf "$protocol" echo "$url" \ | sed 's%^'"$protocol"'%%' \ | sed 's%/.*%%' } # Sanitize a template-string. # AKA, escape quotation-marks. # prep_template $template prep_template() { local template="$1" echo "$template" \ | sed 's%\"%\\\"%g' } # Rough replacement for gettext’s envsubst. Safe! # This will evaluate a string’s shell variables (somewhat) safely # Probably not good for general use — but for our purposes, it only subsitutes # along the “first level.” So environment variables are replaced, but those # variables’ contents (AKA, post contents) aren’t evaluated. # env_subst $template # env_subst "$SHELL" → "/bin/sh" env_subst() { local template="$1" eval "echo \"$(prep_template "$template")\"" } # Based on the environment variable $REVERSE_ORDER (whether or not user provided # the -I flag), reverse the JSON array over stdin. # This is used to enable/disable reverse-chronological order of posts. # fetch_context $url | jq '.descendants' | maybe_jq_reverse maybe_jq_reverse() { local input="$(cat)" if test -n "$REVERSE_ORDER"; then echo "$input" \ | jq 'reverse' else echo "$input" fi } usage() { echo "usage: $(basename "$0") [-h] [-IR] POST_URL" echo echo "$(basename "$0") does exactly what it says on the tin: It formats" echo "a fediverse post (and its replies) into simple-and-embeddable HTML." echo echo "It works with posts from any server that supports Mastodon’s API," echo "including Pleroma, Akkoma, Glitch, etc." echo echo "Notably, it supports post-atachments and custom-emoji. Keep in mind" echo "that images are all fetched from remote sources. It is recommended," echo "if privacy or total archival, is a concern, to use wget(1)’s --mirror" echo "(or something like it) to fetch even these foreign files." echo echo " -h print this message and exit" echo " -I display posts in reverse-chronological order" echo " -R do not recursively display posts’ responses" } while getopts 'hRI' arg; do case $arg in h) usage exit 0 ;; I) REVERSE_ORDER="1" ;; R) NO_RESPONSES="1" ;; esac done shift $((OPTIND-1)) URL="$1" if test -z "$URL"; then usage 1>&2 exit 2 fi POST="$(fetch_post "$URL")" RESPONSES="$(fetch_post_context "$URL" | context_to_responses)" render_post "$POST" "$RESPONSES" 0