#!/usr/bin/env bash
# <xbar.title>Air Story</xbar.title>
# <xbar.version>v0.1</xbar.version>
# <xbar.author>Air Story</xbar.author>
# <xbar.author.github>dhakimzadeh</xbar.author.github>
# <xbar.desc>The air you breathe, in your menu bar: what today's air is doing, why, and where it was 3 days ago. Live NOAA / NWS data, no sign-up.</xbar.desc>
# <xbar.abouturl>https://www.theairstory.com/mac</xbar.abouturl>
# <xbar.dependencies>curl</xbar.dependencies>
# <xbar.var>string(VAR_LAT="32.9"): Latitude, rounded to 1 decimal (about 7 miles).</xbar.var>
# <xbar.var>string(VAR_LON="-117.1"): Longitude, rounded to 1 decimal (about 7 miles).</xbar.var>
# <xbar.var>select(VAR_UNITS="f"): Units. [f, c]</xbar.var>
# <swiftbar.title>Air Story</swiftbar.title>
# <swiftbar.version>v0.1</swiftbar.version>
# <swiftbar.author>Air Story</swiftbar.author>
# <swiftbar.desc>The air you breathe, in your menu bar. Live NOAA / NWS data, no sign-up.</swiftbar.desc>
# <swiftbar.abouturl>https://www.theairstory.com/mac</swiftbar.abouturl>
# <swiftbar.hideRunInTerminal>true</swiftbar.hideRunInTerminal>
# <swiftbar.hideDisablePlugin>true</swiftbar.hideDisablePlugin>
#
# Air Story menu bar plugin for SwiftBar and xbar.
# - Refreshes every 15 minutes (the "15m" in the file name). Please don't rename it
#   to refresh faster: the answer only changes every 15 minutes anyway.
# - Your spot is rounded to 0.1 degree (about 7 miles) before it leaves your Mac.
#   Nothing is stored.
# - Works with stock macOS: bash 3.2, curl, and JavaScript for Automation
#   (osascript) to read JSON. Falls back to python3 where osascript is missing.
#
# Settings: xbar shows LAT, LON and UNITS in the plugin's settings. In SwiftBar,
# edit the three defaults below (or set VAR_LAT / VAR_LON / VAR_UNITS).

DEFAULT_LAT="32.9"
DEFAULT_LON="-117.1"
DEFAULT_UNITS="f"

LAT="${VAR_LAT:-$DEFAULT_LAT}"
LON="${VAR_LON:-$DEFAULT_LON}"
UNITS="${VAR_UNITS:-$DEFAULT_UNITS}"
API="${AIRSTORY_API:-https://www.theairstory.com}"
SITE="https://www.theairstory.com"
CREDIT_DEFAULT="NOAA / NWS · theairstory.com"

# Round to 1 decimal; prints nothing if the value isn't a number.
round1() {
  LC_ALL=C awk -v v="$1" 'BEGIN {
    if (v !~ /^[[:space:]]*[-+]?([0-9]+\.?[0-9]*|\.[0-9]+)[[:space:]]*$/) exit 1
    s = sprintf("%.1f", v + 0); if (s == "-0.0") s = "0.0"; print s
  }'
}

# Error state: hollow dot in the bar, reason in the dropdown.
fail() {
  echo "○ Air Story"
  echo "---"
  echo "$1"
  [ -n "$2" ] && echo "$2 | size=12 color=gray"
  echo "---"
  echo "Open Air Story | href=$SITE/"
  echo "Try again | refresh=true"
  exit 0
}

# JSON -> "key=value" lines. JavaScript for Automation ships with every Mac.
jxa_src() {
  cat <<'JS'
function run(argv) {
  var o, out = [];
  try { o = JSON.parse(argv[0]); } catch (e) { return "error=badjson"; }
  if (!o || typeof o !== "object") return "error=badjson";
  function clean(s) { return (s === null || s === undefined) ? "" : String(s).replace(/[\r\n|]+/g, " ").replace(/^\s+|\s+$/g, ""); }
  function num(n) { return (typeof n === "number" && isFinite(n)) ? String(Math.round(n)) : ""; }
  function commas(s) { return s.replace(/\B(?=(\d{3})+(?!\d))/g, ","); }
  function put(k, v) { out.push(k + "=" + v); }
  if (o.error) { put("error", clean(o.error)); return out.join("\n"); }
  var now = o.now || {}, labels = o.labels || {}, trail = o.trail;
  put("key", clean(o.key));
  put("chip", clean(o.chip));
  put("headline", clean(o.headline));
  put("color", /^#[0-9a-fA-F]{3,8}$/.test(o.color || "") ? o.color : "");
  put("place", clean(o.place));
  put("units", o.units === "c" ? "c" : "f");
  put("temp", num(now.temp));
  put("dew", num(now.dew));
  put("rh", num(now.rh));
  put("tlabel", clean(labels.temp));
  put("dlabel", clean(labels.dist));
  if (trail && typeof trail === "object") {
    put("from", clean(trail.from));
    put("miles", num(trail.miles));
    put("km", commas(num(trail.km)));
    put("milesfmt", commas(num(trail.miles)));
  }
  var alerts = o.alerts instanceof Array ? o.alerts : [];
  for (var i = 0; i < alerts.length && i < 3; i++) put("alert", clean(alerts[i]));
  put("link", /^https:\/\//.test(o.link || "") ? clean(o.link) : "");
  put("credit", clean(o.credit));
  return out.join("\n");
}
JS
}

py_src() {
  cat <<'PY'
import json, math, re, sys
def clean(s):
    return "" if s is None else re.sub(r"[\r\n|]+", " ", str(s)).strip()
def num(n):
    return str(int(math.floor(n + 0.5))) if isinstance(n, (int, float)) and not isinstance(n, bool) and math.isfinite(n) else ""
def commas(s):
    return re.sub(r"\B(?=(\d{3})+(?!\d))", ",", s)
out = []
put = lambda k, v: out.append(k + "=" + v)
try:
    o = json.loads(sys.argv[1])
    assert isinstance(o, dict)
except Exception:
    print("error=badjson"); sys.exit(0)
if o.get("error"):
    put("error", clean(o["error"]))
else:
    now, labels, trail = o.get("now") or {}, o.get("labels") or {}, o.get("trail")
    put("key", clean(o.get("key")))
    put("chip", clean(o.get("chip")))
    put("headline", clean(o.get("headline")))
    put("color", o["color"] if re.match(r"^#[0-9a-fA-F]{3,8}$", o.get("color") or "") else "")
    put("place", clean(o.get("place")))
    put("units", "c" if o.get("units") == "c" else "f")
    put("temp", num(now.get("temp")))
    put("dew", num(now.get("dew")))
    put("rh", num(now.get("rh")))
    put("tlabel", clean(labels.get("temp")))
    put("dlabel", clean(labels.get("dist")))
    if isinstance(trail, dict):
        put("from", clean(trail.get("from")))
        put("miles", num(trail.get("miles")))
        put("km", commas(num(trail.get("km"))))
        put("milesfmt", commas(num(trail.get("miles"))))
    alerts = o.get("alerts") if isinstance(o.get("alerts"), list) else []
    for a in alerts[:3]:
        put("alert", clean(a))
    put("link", clean(o.get("link")) if str(o.get("link") or "").startswith("https://") else "")
    put("credit", clean(o.get("credit")))
print("\n".join(out))
PY
}

parse_json() {
  if command -v osascript >/dev/null 2>&1; then
    osascript -l JavaScript -e "$(jxa_src)" "$1" 2>/dev/null
  elif command -v python3 >/dev/null 2>&1; then
    python3 -c "$(py_src)" "$1" 2>/dev/null
  else
    echo "error=noparser"
  fi
}

# --- Settings ---------------------------------------------------------------

RLAT=$(round1 "$LAT") || RLAT=""
RLON=$(round1 "$LON") || RLON=""
if [ -z "$RLAT" ] || [ -z "$RLON" ]; then
  fail "Check LAT and LON in the plugin settings." "Use decimal degrees, like 32.9 and -117.1."
fi
case "$UNITS" in c|C) UNITS="c" ;; *) UNITS="f" ;; esac

URL="$API/api/v1/now?lat=$RLAT&lon=$RLON&client=menubar"
[ "$UNITS" = "c" ] && URL="$URL&u=c"

# --- Fetch ------------------------------------------------------------------

RESP=$(curl -s --max-time 10 -H "Accept: application/json" -A "AirStory-menubar/0.1" -w '
%{http_code}' "$URL" 2>/dev/null)
NL='
'
STATUS=${RESP##*"$NL"}
BODY=${RESP%"$NL"*}
[ "$RESP" = "$STATUS" ] && BODY=""

case "$STATUS" in
  200) ;;
  404) fail "Outside US coverage for now." "Air Story's live answer covers the US so far." ;;
  503) fail "Air Story is taking a breather." "Back soon. Nothing to do on your end." ;;
  400) fail "Check LAT and LON in the plugin settings." "Use decimal degrees, like 32.9 and -117.1." ;;
  000|"") fail "Offline: couldn't reach Air Story." "It will try again in 15 minutes." ;;
  *) fail "Air Story didn't answer (HTTP $STATUS)." "It will try again in 15 minutes." ;;
esac

# --- Parse ------------------------------------------------------------------

KEY="" CHIP="" HEADLINE="" COLOR="" PLACE="" TEMP="" DEW="" RH="" TLABEL="" DLABEL=""
FROM="" MILES="" MILESFMT="" KM="" LINK="" CREDIT="" ERR="" ALERTS=""
PARSED=$(parse_json "$BODY")
while IFS= read -r line; do
  k=${line%%=*}
  v=${line#*=}
  case "$k" in
    error) ERR=$v ;;
    key) KEY=$v ;;
    chip) CHIP=$v ;;
    headline) HEADLINE=$v ;;
    color) COLOR=$v ;;
    place) PLACE=$v ;;
    temp) TEMP=$v ;;
    dew) DEW=$v ;;
    rh) RH=$v ;;
    tlabel) TLABEL=$v ;;
    dlabel) DLABEL=$v ;;
    from) FROM=$v ;;
    miles) MILES=$v ;;
    milesfmt) MILESFMT=$v ;;
    km) KM=$v ;;
    link) LINK=$v ;;
    credit) CREDIT=$v ;;
    alert) ALERTS="$ALERTS$v$NL" ;;
  esac
done <<EOF
$PARSED
EOF

case "$ERR" in
  "") ;;
  outside_coverage) fail "Outside US coverage for now." "Air Story's live answer covers the US so far." ;;
  paused) fail "Air Story is taking a breather." "Back soon. Nothing to do on your end." ;;
  noparser) fail "Couldn't read the answer on this Mac." "Needs osascript (built into macOS) or python3." ;;
  *) fail "Air Story sent an answer this plugin can't read." "Updating the plugin may help." ;;
esac
[ -z "$HEADLINE" ] && fail "Air Story sent an answer this plugin can't read." "Updating the plugin may help."

[ -z "$TLABEL" ] && { [ "$UNITS" = "c" ] && TLABEL="°C" || TLABEL="°F"; }
[ -z "$LINK" ] && LINK="$SITE/?at=$RLAT,$RLON&utm_source=api&utm_medium=menubar"
[ -z "$CREDIT" ] && CREDIT="$CREDIT_DEFAULT"
[ "$KEY" = "calm" ] && CHIP=""

# --- Menu bar line ----------------------------------------------------------

TEMP_SHORT=""
[ -n "$TEMP" ] && TEMP_SHORT="${TEMP}°"
if [ -n "$CHIP" ]; then
  TITLE="● $CHIP${TEMP_SHORT:+ $TEMP_SHORT}"
  [ -n "$COLOR" ] && TITLE="$TITLE | color=$COLOR"
else
  TITLE="● ${TEMP_SHORT:-Air Story}"
fi
echo "$TITLE"
echo "---"

# --- Dropdown ---------------------------------------------------------------

HEAD_LABEL=${CHIP:-Right now}
[ -n "$PLACE" ] && HEAD_LABEL="$HEAD_LABEL · $PLACE"
if [ -n "$COLOR" ] && [ -n "$CHIP" ]; then
  echo "$HEAD_LABEL | size=12 color=$COLOR"
else
  echo "$HEAD_LABEL | size=12"
fi
echo "$HEADLINE"

if [ -n "$TEMP" ]; then
  NUMS="${TEMP}${TLABEL}"
  [ -n "$DEW" ] && NUMS="$NUMS · dew point ${DEW}°"
  [ -n "$RH" ] && NUMS="$NUMS · ${RH}% humidity"
  echo "$NUMS"
fi

if [ -n "$MILES" ]; then
  if [ "$MILES" -lt 120 ]; then
    echo "Stayed close to home"
  elif [ "$UNITS" = "c" ] && [ -n "$KM" ]; then
    echo "3 days ago: $KM ${DLABEL:-km}${FROM:+ $FROM}"
  else
    echo "3 days ago: $MILESFMT ${DLABEL:-mi}${FROM:+ $FROM}"
  fi
fi

if [ -n "$ALERTS" ]; then
  echo "---"
  while IFS= read -r a; do
    [ -n "$a" ] && echo "⚠ $a | color=#ff8a5c"
  done <<EOF
$ALERTS
EOF
fi

echo "---"
echo "Open Air Story | href=$LINK"
echo "Air Story everywhere | href=$SITE/everywhere"
echo "$CREDIT | size=11 color=gray"
