# ============================================================
# TerminalWidget β USD Exchange Rates
#
# Updates eight TerminalWidget targets:
#
# exchange-rates compact summary table
#
# exchange-zar 30-day ZAR chart
# exchange-zmw 30-day ZMW chart
# exchange-zwg 30-day ZWG chart
# exchange-mzn 30-day MZN chart
# exchange-mwk 30-day MWK chart
# exchange-gbp 30-day GBP chart
# exchange-eur 30-day EUR chart
#
# Data source:
# fxapi.app
#
# Current rates:
# One request retrieves all USD rates.
#
# Historical rates:
# 30 daily observations, cached locally once per day.
#
# Recommended LaunchAgent interval:
# 15 minutes
# ============================================================
Script
Foreign Exchange Rates
Script (exchange-rates-widget.sh)
#!/bin/zsh
# ============================================================
# TerminalWidget β USD Exchange Rates
#
# Updates eight TerminalWidget targets:
#
# exchange-rates compact summary table
#
# exchange-zar 30-day ZAR chart
# exchange-zmw 30-day ZMW chart
# exchange-zwg 30-day ZWG chart
# exchange-mzn 30-day MZN chart
# exchange-mwk 30-day MWK chart
# exchange-gbp 30-day GBP chart
# exchange-eur 30-day EUR chart
#
# Data source:
# fxapi.app
#
# Current rates:
# One request retrieves all USD rates.
#
# Historical rates:
# 30 daily observations, cached locally once per day.
#
# Recommended LaunchAgent interval:
# 15 minutes
# ============================================================
set -u
# ------------------------------------------------------------
# Configuration
# ------------------------------------------------------------
TW="/Applications/TerminalWidget.app/Contents/MacOS/TerminalWidget"
API_BASE="https://fxapi.app/api"
STATE_DIR="$HOME/Library/Application Support/TerminalWidgetFX"
mkdir -p "$STATE_DIR"
# ------------------------------------------------------------
# Currencies
# ------------------------------------------------------------
CURRENCIES=(
ZAR
ZMW
ZWG
MZN
MWK
GBP
EUR
)
# ------------------------------------------------------------
# Currency flags
# ------------------------------------------------------------
typeset -A FLAG
FLAG=(
USD "πΊπΈ"
ZAR "πΏπ¦"
ZMW "πΏπ²"
ZWG "πΏπΌ"
MZN "π²πΏ"
MWK "π²πΌ"
GBP "π¬π§"
EUR "πͺπΊ"
)
# ------------------------------------------------------------
# Dates
#
# Today plus the preceding 29 days gives 30 calendar days.
# ------------------------------------------------------------
TODAY="$(date '+%Y-%m-%d')"
FROM_DATE="$(date -v-29d '+%Y-%m-%d')"
FROM_LABEL="$(date -v-29d '+%d %b')"
TO_LABEL="$(date '+%d %b')"
# Remove leading zero:
#
# 03 Aug -> 3 Aug
# ------------------------------------------------------------
FROM_LABEL="${FROM_LABEL#0}"
TO_LABEL="${TO_LABEL#0}"
# ------------------------------------------------------------
# Locate jq
#
# launchd normally has a restricted PATH, so explicitly
# check the common Homebrew locations.
# ------------------------------------------------------------
if [[ -x "/opt/homebrew/bin/jq" ]]; then
JQ="/opt/homebrew/bin/jq"
elif [[ -x "/usr/local/bin/jq" ]]; then
JQ="/usr/local/bin/jq"
else
JQ="$(command -v jq 2>/dev/null || true)"
fi
if [[ -z "${JQ:-}" || ! -x "$JQ" ]]; then
echo "Error: jq not found." >&2
echo >&2
echo "Install it with:" >&2
echo >&2
echo " brew install jq" >&2
exit 1
fi
# ------------------------------------------------------------
# Check TerminalWidget
# ------------------------------------------------------------
if [[ ! -x "$TW" ]]; then
echo "Error: TerminalWidget binary not found:" >&2
echo >&2
echo " $TW" >&2
exit 1
fi
# ------------------------------------------------------------
# Format exchange rate
#
# Examples:
#
# 16.2387 -> 16.24
# 1735.3298 -> 1,735.33
# 12345.6789 -> 12,345.68
#
# Uses native zsh rather than awk.
# ------------------------------------------------------------
format_rate()
{
local VALUE="$1"
local ROUNDED
local INTEGER
local DECIMAL
local SIGN=""
local GROUPED=""
local CHUNK
# Round to exactly two decimal places.
ROUNDED="$(LC_ALL=C printf "%.2f" "$VALUE")"
# Separate integer and decimal portions.
INTEGER="${ROUNDED%%.*}"
DECIMAL="${ROUNDED##*.}"
# Preserve negative sign if ever required.
if [[ "$INTEGER" == -* ]]; then
SIGN="-"
INTEGER="${INTEGER#-}"
fi
# Insert thousands separators from right to left.
while (( ${#INTEGER} > 3 )); do
CHUNK="${INTEGER[-3,-1]}"
GROUPED=",${CHUNK}${GROUPED}"
INTEGER="${INTEGER[1,-4]}"
done
printf "%s%s%s.%s" \
"$SIGN" \
"$INTEGER" \
"$GROUPED" \
"$DECIMAL"
}
# ============================================================
#
# CURRENT EXCHANGE RATES
#
# ============================================================
CURRENT_JSON="$STATE_DIR/current-usd.json"
CURRENT_TMP="$STATE_DIR/current-usd.tmp"
CURRENT_STALE=0
# ------------------------------------------------------------
# Fetch all USD rates
#
# One fxapi.app request returns every target currency.
# ------------------------------------------------------------
if /usr/bin/curl \
--fail \
--silent \
--show-error \
--location \
--compressed \
--connect-timeout 5 \
--max-time 15 \
"$API_BASE/usd.json" \
> "$CURRENT_TMP"
then
# Check that the response looks valid.
if "$JQ" -e \
'.rates and (.rates | type == "object")' \
"$CURRENT_TMP" \
>/dev/null 2>&1
then
mv "$CURRENT_TMP" "$CURRENT_JSON"
else
rm -f "$CURRENT_TMP"
CURRENT_STALE=1
fi
else
rm -f "$CURRENT_TMP"
CURRENT_STALE=1
fi
# ------------------------------------------------------------
# Require either fresh or cached current rates
# ------------------------------------------------------------
if [[ ! -s "$CURRENT_JSON" ]]; then
echo "Error: no current exchange-rate data available." >&2
exit 1
fi
# ------------------------------------------------------------
# Absolute path to this script
#
# Clicking any widget runs this command and therefore updates
# all eight widgets.
# ------------------------------------------------------------
SCRIPT_PATH="${0:A}"
REFRESH_COMMAND="/bin/zsh \"$SCRIPT_PATH\""
# ============================================================
#
# SUMMARY WIDGET
#
# Target:
#
# exchange-rates
#
# IMPORTANT:
#
# TerminalWidget's sandbox may prevent it reading a table file
# directly from ~/Library/Application Support.
#
# We therefore generate TSV and pipe it directly to:
#
# --table -
#
# TerminalWidget does not support --title with table widgets,
# so the first table row serves as the heading.
#
# ============================================================
{
# Header row
printf "Currency\t%s 1 USD\n" "${FLAG[USD]}"
# Currency rows
for CURRENCY in $CURRENCIES
do
CURRENT_RATE="$(
"$JQ" -er \
--arg currency "$CURRENCY" \
'.rates[$currency] | select(type == "number")' \
"$CURRENT_JSON" \
2>/dev/null
)" || CURRENT_RATE=""
if [[ -n "$CURRENT_RATE" ]]; then
DISPLAY_RATE="$(format_rate "$CURRENT_RATE")"
printf "%s %s\t%s\n" \
"${FLAG[$CURRENCY]}" \
"$CURRENCY" \
"$DISPLAY_RATE"
fi
done
} |
"$TW" \
--target "exchange-rates" \
--table - \
--grid none \
--table-layout auto \
--font-size 10 \
--padding 4 \
--mode light \
--bg-gradient-from "#D7EAD9" \
--bg-gradient-to "#EFF7F0" \
--bg-gradient-start nw \
--text-color "#183B26" \
--title-color "#285D37" \
--action-kind run-command \
--action-value "$REFRESH_COMMAND"
# ============================================================
#
# INDIVIDUAL 30-DAY GRAPH WIDGETS
#
# ============================================================
for CURRENCY in $CURRENCIES
do
LOWER="${(L)CURRENCY}"
TARGET="exchange-${LOWER}"
# --------------------------------------------------------
# Current rate
# --------------------------------------------------------
CURRENT_RATE="$(
"$JQ" -er \
--arg currency "$CURRENCY" \
'.rates[$currency] | select(type == "number")' \
"$CURRENT_JSON" \
2>/dev/null
)" || CURRENT_RATE=""
if [[ -z "$CURRENT_RATE" ]]; then
echo "Warning: no current USD/$CURRENCY rate." >&2
continue
fi
# --------------------------------------------------------
# Historical-data files
#
# A new cache file is created each calendar day.
# --------------------------------------------------------
HISTORY_FILE="$STATE_DIR/history-usd-${LOWER}-${TODAY}.json"
HISTORY_TMP="$STATE_DIR/history-usd-${LOWER}-${TODAY}.tmp"
# --------------------------------------------------------
# Fetch 30-day history
#
# Only fetch if today's cached historical response does
# not already exist.
# --------------------------------------------------------
if [[ ! -s "$HISTORY_FILE" ]]; then
HISTORY_URL="${API_BASE}/history/usd/${LOWER}.json?from=${FROM_DATE}&to=${TODAY}"
if /usr/bin/curl \
--fail \
--silent \
--show-error \
--location \
--compressed \
--connect-timeout 5 \
--max-time 20 \
"$HISTORY_URL" \
> "$HISTORY_TMP"
then
# Confirm that the response contains rate data.
if "$JQ" -e \
'(.rates | type == "array") and ((.rates | length) > 0)' \
"$HISTORY_TMP" \
>/dev/null 2>&1
then
mv "$HISTORY_TMP" "$HISTORY_FILE"
else
rm -f "$HISTORY_TMP"
fi
else
rm -f "$HISTORY_TMP"
fi
fi
# --------------------------------------------------------
# Build 30-day chart
#
# Historical observations are sorted by date.
#
# If the historical endpoint already contains today's
# value, today's historical observation is removed.
#
# The current live rate is then appended so the final
# graph point represents NOW.
# --------------------------------------------------------
if [[ -s "$HISTORY_FILE" ]]; then
CHART="$(
{
"$JQ" -r \
--arg today "$TODAY" \
'
.rates
| sort_by(.date)
| .[]
| select(.date != $today)
| .rate
' \
"$HISTORY_FILE"
printf '%s\n' "$CURRENT_RATE"
} |
tail -n 30 |
paste -sd ' ' -
)"
else
# Historical service unavailable:
# still display current exchange rate.
CHART="$CURRENT_RATE"
fi
# --------------------------------------------------------
# Format current rate
# --------------------------------------------------------
DISPLAY_RATE="$(format_rate "$CURRENT_RATE")"
# --------------------------------------------------------
# Graph title
#
# Examples:
#
# πΊπΈ USD β πͺπΊ EUR Β· 30D
# πΊπΈ USD β πΏπ¦ ZAR Β· 30D
# --------------------------------------------------------
TITLE="${FLAG[USD]} USD β ${FLAG[$CURRENCY]} ${CURRENCY} Β· 30D"
# --------------------------------------------------------
# Centre caption
# --------------------------------------------------------
if (( CURRENT_STALE )); then
CENTER_LABEL="${DISPLAY_RATE} ${CURRENCY} Β· cached"
else
CENTER_LABEL="${DISPLAY_RATE} ${CURRENCY}"
fi
# --------------------------------------------------------
# Update individual graph widget
#
# --chart-format graph
# connected line with point markers
#
# --label-y
# five numerical Y-axis labels
#
# --caption-left/right
# beginning/end of 30-day period
#
# --caption-text
# current exchange rate in centre
#
# We deliberately do NOT use --base-zero because FX
# movements would otherwise be visually flattened.
# --------------------------------------------------------
"$TW" \
--target "$TARGET" \
--title "$TITLE" \
--title-alignment center \
--chart "$CHART" \
--chart-format graph \
--chart-height "75%" \
--label-y \
--caption-left "$FROM_LABEL" \
--caption-text "$CENTER_LABEL" \
--caption-right "$TO_LABEL" \
--mode light \
--bg-gradient-from "#D7EAD9" \
--bg-gradient-to "#EFF7F0" \
--bg-gradient-start nw \
--text-color "#183B26" \
--title-color "#285D37" \
--action-kind run-command \
--action-value "$REFRESH_COMMAND"
done
# ============================================================
#
# CLEANUP
#
# ============================================================
# Keep approximately one week's historical cache files.
find "$STATE_DIR" \
-type f \
-name 'history-*.json' \
-mtime +7 \
-delete \
2>/dev/null
exit 0
Running in the background with launchd
Suggested interval: 15 minutes (StartInterval = 900 seconds).
Save the script from this recipe to ~/bin/foreign-exchange-rates.sh, then create a Launch Agent plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.terminalwidget.foreign-exchange-rates</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>~/bin/foreign-exchange-rates.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>900</integer>
<key>StandardOutPath</key>
<string>/tmp/com.terminalwidget.foreign-exchange-rates.log</string>
<key>StandardErrorPath</key>
<string>/tmp/com.terminalwidget.foreign-exchange-rates.err</string>
</dict>
</plist>
From Terminal:
mkdir -p ~/bin ~/Library/LaunchAgents
# Save your script to ~/bin/foreign-exchange-rates.sh
cat > ~/Library/LaunchAgents/com.terminalwidget.foreign-exchange-rates.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.terminalwidget.foreign-exchange-rates</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>~/bin/foreign-exchange-rates.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>900</integer>
<key>StandardOutPath</key>
<string>/tmp/com.terminalwidget.foreign-exchange-rates.log</string>
<key>StandardErrorPath</key>
<string>/tmp/com.terminalwidget.foreign-exchange-rates.err</string>
</dict>
</plist>
EOF
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.terminalwidget.foreign-exchange-rates.plist
For a GUI editor and troubleshooting, see LaunchControl from soma-zone.