Skip to content

Instantly share code, notes, and snippets.

@pmarreck
Last active October 31, 2024 14:14
Show Gist options
  • Save pmarreck/18a27a2af8688bef2f51992763ded238 to your computer and use it in GitHub Desktop.
Save pmarreck/18a27a2af8688bef2f51992763ded238 to your computer and use it in GitHub Desktop.
Estee Lauder application: A TUI function named "eat" that gets you to where the good stuff is.
#!/bin/sh
### EAT.BASH
### USAGE:
# 1) Run this file directly after making it executable and pass in an (optional) filter argument, e.g.,
# ./eat.bash burrito
# If you happen to have Nix installed, it will guarantee (more or less) that it will work,
# but it will download the deps on first run (and cache them).
# You can also run (or call) it with no arguments and just browse all the results
# sorted by distance: `eat` or `./eat.bash`
# 2) Source this file from somewhere else and then call `eat burrito` from shell etc.
# Note that some of the code expects a more recent gnu-awk and bash version
# than is usually stock on macOS in order to work properly.
# If you are on macOS, these can be installed via Homebrew, or Nix.
# (The code will warn you if it thinks deps are missing or their versions are wrong.)
# 3) `./eat.bash TEST` or `TEST=1 ./eat.bash` will run the test suite.
### FEATURES
# 1) Checks for executable dependencies/versions it needs and shows you where to get them
# 2) Looks up your current location (approximately; can be overridden with env vars)
# 3) Downloads the CSV and caches it locally for a week
# 4) Prefilters only on locations whose status is APPROVED or REQUESTED
# 5) Uses csvquote to handle quotes/commas/etc in CSV data
# 6) Takes an argument to pre-filter the results (ex. "eat chinese")
# 7) Sorts the result set by crow's distance from you using lat-long mathy-math
# 8) Displays the results in a nice TUI, letting you scroll through them and hit Enter to select one
# 9) Finally, prints the selected result.
# 10) Also contains a test suite!
### DEPENDENCY MANAGEMENT
# This section implements dependency management through Nix, provided it's installed
# and the script is run as a standalone file. In such a case, the script re-executes
# itself under Bash after automatically resolving and caching dependencies. This
# approach is advantageous as it potentially ensures the script's continuous functionality,
# irrespective of future changes.
# In the absence of Nix, the script simply reruns itself with the existing Bash
# environment. However, under these conditions, we cannot guarantee future-proof
# execution.
# While Nix is a powerful tool, we recognize it may not be a universal choice.
# Therefore, we haven't made it mandatory. The implementation of Nix in this script
# mainly serves as a demonstration of its capabilities.
# Please note that if the file is sourced, this section is bypassed entirely. This is
# due to the fact that we cannot regulate the environment of the sourcing code.
# Lastly, to maintain a clean environment, any changes made during the execution are
# reversed at the end of the script.
# Everything up to the two relevant `exec` lines SHOULD be POSIX-compliant, ideally.
(return 0 2>/dev/null) && _sourced=1 || _sourced=0
if [ "$_sourced" -eq "0" ]; then
# Only do the following if NOT sourced.
# If sourced, we have no control over the parent environment anyway so
# skip this and just rely on the other presence and version checks.
if [ ! "$_nix_wrapper_magic" = "1" ]; then
# Only do the following if we didn't already get here via the "wrapper magic"
if ! command -v nix > /dev/null; then
# You don't have Nix, so just use any existing Bash
_nix_wrapper_magic=1 exec bash "$0" "$@"
fi
# You DO have Nix, so adjust its pinned packages source, deps and allowed env vars.
# Wanted to pin to 22.11 but only unstable branch had csvquote, so I picked
# a recent commit instead.
nixpkgs="github:NixOS/nixpkgs/da45bf6ec7bbcc5d1e14d3795c025199f28e0de0"
# list dependencies here:
deps="bashInteractive gawkInteractive jq curl csvquote gum"
# ENV vars to allow into the pure environment here:
# These tend to cause bugs if not included
must_include_env_vars="HOME PATH COLORTERM LANG LC_CTYPE LANGUAGE"
# Allow any XDG_* vars
xdg_vars=`env | awk -F= '$1 ~ /^XDG_[A-Z_]+$/ { print $1 }'`
# Allow any "gum" config vars
gum_vars=`env | awk -F= '$1 ~ /^GUM_[A-Z_]+$/ { print $1 }'`
# Allow any config vars for THIS script
food_csv_vars="`env | awk -F= '$1 ~ /^FOOD_CSV_[A-Z_]+$/ { print $1 }'` MY_LAT MY_LONG"
allowed_env_vars="$must_include_env_vars $xdg_vars $gum_vars $food_csv_vars"
# Reformat deps and allowed_env_vars to be used as nix shell arguments:
deps="`for d in $deps; do printf '%s#%s ' $nixpkgs $d; done`"
allowed_env_vars="`for ev in $allowed_env_vars; do printf -- '-k %s ' $ev; done`"
# THIS IS THE MAIN MAGIC NIX BIT:
_nix_wrapper_magic=1 exec nix shell -k _nix_wrapper_magic $allowed_env_vars -i $deps -c bash "$0" "$@"
unset allowed_env_vars food_csv_vars must_include_env_vars gum_vars xdg_vars deps nixpkgs
fi
unset _nix_wrapper_magic
fi
unset _sourced
### END DEPENDENCY MANAGEMENT
# Normal Bash script goes below this line
# graceful dependency checking
needs() {
local bin=$1
shift
command -v $bin > /dev/null 2>&1 || {
printf "'Eat' requires %s but it's not installed or in PATH; %s\n" "$bin" "$*" 1>&2
return 1
}
}
# graceful (?) version checking
version() {
local bin="$1"
local version="$2"
shift 2
local instructions="$*" # optional
# check that the version is the minimum desired based on a regex
if ! $bin --version | grep -q "$version"; then
printf "'Eat' requires %s %s but you have %s; %s\n" "$bin" "$(echo $version | tr -d '\')" "$($bin --version | head -n1)" "$instructions" 1>&2
printf '%s\n' "It may work, but expected results are less guaranteed. " 1>&2
return 1
fi
}
# Satisfy prereqs
# macos needs gnu awk (gawk)
if [[ $(uname) == "Darwin" ]]; then
needs gawk "install gawk with 'brew install gawk'"
AWK="gawk"
else
needs awk "instructions vary by distro"
AWK="awk"
fi
needs jq https://stedolan.github.io/jq/download/
needs curl "instructions vary per distro, or use homebrew on mac"
needs csvquote https://github.com/dbro/csvquote
needs gum https://github.com/charmbracelet/gum
version bash 'version 5\.'
version curl 'curl 8\.'
version $AWK 'GNU Awk 5\.'
# Eat: Because people gotta.
# First argument is what to pre-filter the results on
# (for example, "burrito"). Case-insensitive. Optional. Can be double-quoted for multiple words.
eat() {
local location_data latitude longitude
local csv_cache_dir csv_file csv_lines escaped_csv active_csv filtered_csv sorted_csv selected
local csv_url=${FOOD_CSV_URL:-"https://data.sfgov.org/api/views/rqzj-sfat/rows.csv"}
# 1. Determine the geolocated latitude/longitude if not set by an env.
if [ -z "$MY_LAT" ] || [ -z "$MY_LONG" ]; then
location_data=$(curl -s https://ipapi.co/json/)
latitude=$(echo "$location_data" | jq -r '.latitude')
longitude=$(echo "$location_data" | jq -r '.longitude')
else
latitude="$MY_LAT"
longitude="$MY_LONG"
fi
# 2. Create the cached foodtruck directory if it doesn't already exist
csv_cache_dir=${FOOD_CSV_CACHE_DIR:-"$HOME/.cache/food"}
mkdir -p "$csv_cache_dir"
# 3. Compute the final data cache path
csv_file="$csv_cache_dir/${FOOD_CSV_FILENAME:-"trucks.csv"}"
# 4. Look for a cached version of the CSV file at "$csv_file"
# If it doesn't exist or is older than 7 days, delete it and re-download it to that path using the URL above.
if [[ ! -f "$csv_file" ]] || [[ $(find "$csv_file" -mtime +7) ]]; then
curl -s "$csv_url" -o "$csv_file"
fi
# 5. Bail if the csv file seems empty (sanity check)
csv_lines=$(wc -l < "$csv_file")
if [ $csv_lines -lt 2 ]; then
echo "The number of lines in the data file at $csv_file is less than 2; something is likely wrong." >&2
return 1
fi
# 6. Escape commas between double quotes using csvquote
escaped_csv=$(csvquote < "$csv_file")
# 7. Remove lines where the "Status" is not "REQUESTED" or "APPROVED"
active_csv=$(echo "$escaped_csv" | $AWK -F, 'NR==1 || $11=="APPROVED" || $11=="REQUESTED"')
# 8. Filter the list based on any search criteria passed in (case-insensitive)
if [[ "$1" != "" ]]; then
filtered_csv=$(echo "$active_csv" | $AWK -v search_word="$1" 'NR==1 || tolower($0) ~ tolower(search_word)')
else
filtered_csv=$active_csv
fi
# 9. Sort results by distance from the current latitude/longitude; shortest at top
sorted_csv=$(echo "$filtered_csv" | $AWK -v lat="$latitude" -v lon="$longitude" -F, '
function haversine(lat1, lon1, lat2, lon2, a, b, c, d, r) {
a = sin((lat2 - lat1) / 2) ^ 2
b = cos(lat1) * cos(lat2) * sin((lon2 - lon1) / 2) ^ 2
c = 2 * atan2(sqrt(a + b), sqrt(1 - a - b))
d = r * c
return d
}
NR==1 {
OFS=","
print "Name", $6, "Food", "Distance (mi)"
next
}
{
lat1 = lat * atan2(0, -1) / 180
lon1 = lon * atan2(0, -1) / 180
lat2 = $15 * atan2(0, -1) / 180
lon2 = $16 * atan2(0, -1) / 180
# The last argument here is the radius of the earth.
# Use 6371 if you want the resulting distance in kilometers
# Use 3958.8 if you want the resulting distance in miles
d = haversine(lat1, lon1, lat2, lon2, 0, 0, 0, 0, 3958.8)
printf "%s,%s,%s,%.2f\n", $2, $6, $12, d
}
' | sort -t, -k4,4n)
# debug to view data in a nice way at any point in stream:
# echo "$sorted_csv" | column -s, -t | less -#2 -N -S --header 1
# 10. Choose one!
if [[ "$TEST" == "1" ]]; then
# we are running in the test suite; just pick the last result
selected=$(echo "$sorted_csv" | tail -n 1)
else
# we're NOT in a test suite and can get fancy (but harder to test).
# I dunno, I really didn't like fzf, fzy or peco for this, but...
# selected=$(echo "$sorted_csv" | peco --prompt="Hungry for:" --query="$1")
# One nice thing about "gum table" is that it outputs pretty-printed, but still passes along
# the comma-delimited data to the next thing in the pipeline so you don't have to re-normalize.
selected=$(echo "$sorted_csv" | gum table --widths=25,20,50,13 --height=20)
fi
# 10. Print the selected line with name (Applicant), Address, FoodItems and Distance
echo "Selected Food Truck:"
echo "$selected" | $AWK -F, '{print "Name: " $1 "\nAddress: " $2 "\nFood: " $3 "\nDistance (mi): " $4}'
}
#### TEST SUITE CODE FROM HERE ON OUT
# Run the test suite by running this file directly with argument TEST (case-sensitive)
# or by setting the TEST environment variable to something
if [ "$1" = "TEST" ] || [ -n "$TEST" ]; then
TEST=1
load_testdata() {
# Clever way to embed any data at the bottom of the file
printf "%s" "$($AWK 'p;/^__TESTDATA__$/{p=1}' "$0")"
}
# I didn't end up using set -e (swallowed too much error info)
# and thus didn't end up using the save_switches and restore_switches functions
# but they're still possibly useful so I left them in.
# Usage:
# saved_state="$(save_switches)" # need quoted to preserve whitespace
save_switches() {
local on_switches off_switches switch
# Gotta make sure not to touch the "interactive" switch (i)
# We also remove it from the set of all switches even though technically it is one
all_switches="abefhkmnptuvxBCEHPT"
on_switches=$(echo -n $- | sed 's/i//')
off_switches=""
for switch in $(echo -n "$all_switches" | fold -w1); do
[[ $on_switches == *"$switch"* ]] || off_switches+="$switch"
done
echo "$on_switches $off_switches"
}
# Usage:
# restore_switches $saved_state # need unquoted to expand whitespace
# to 2 arguments saved by save_switches; yeah, I know.
# I could have used an array here
restore_switches() {
set -$1
set +$2
}
green_text() {
echo -ne "\e[32m${*}\e[0m"
}
yellow_text() {
echo -ne "\e[93m${*}\e[0m"
}
red_text() {
echo -ne "\e[31m${*}\e[0m"
}
fail() {
# First arg: Error message
# Second arg (optional): Expected result
# Third arg (optional): Actual result
# If both second and third are provided, you get a color diff.
red_text "FAIL" >&2
echo -e ": $1" >&2
if [ -n "$2" ] && [ -n "$3" ]; then
yellow_text "Green diff is expected; red diff is actual\n"
diff --color <(echo -ne "$3") <(echo -ne "$2")
fi
exit 1
}
test_suite() {
local current_switches expected_result actual_result exitcode
local FOOD_CSV_CACHE_DIR FOOD_CSV_FILENAME MY_LAT MY_LONG
local testdata=$(load_testdata)
### TEST SETUP
FOOD_CSV_CACHE_DIR="/tmp"
FOOD_CSV_FILENAME="test_trucks.csv"
printf "%s" "$testdata" > "${FOOD_CSV_CACHE_DIR}/${FOOD_CSV_FILENAME}"
# HQ of Estee Lauder San Fran as found by Google Maps:
MY_LAT="37.729495047017664"
MY_LONG="-122.47676462733924"
### BEGIN CASES
# CASE 1: NEED CHURROS, STAT
# There were issues testing against the expected name here if the name contained single quotes or commas.
# I think csvquote mangles the binary of the quotes or commas somehow to get around those csv issues,
# but even directly copying and pasting the actual to the expected wasn't fixing it.
# So I bailed on that for time reasons; checking the address etc. pretty much uniquely identifies it anyway.
expected_result="Address: 101 STOCKTON ST
Food: Soft Pretzels: hot dogs: sausage: chips: popcorn: soda: espresso: cappucino: pastries: ice cream: italian sausages: shish-ka-bob: churros: juice: water: various drinks
Distance (mi): 5.50"
actual_result=$(eat Churros | tail -n 3)
exitcode="$?"
[ "0" = "$exitcode" ] || fail "'eat Churros' exited nonzero" "0" "$exitcode"
grep -qzi "churros" <<< "$actual_result"
exitcode="$?"
[ "0" = "$exitcode" ] || fail "'eat Churros' results should contain the word searched for, in this case 'churros'" "something with 'churros'" "$actual_result"
[ "$expected_result" = "$actual_result" ] || fail "'eat churros' result was unexpected:" "$expected_result" "$actual_result"
# CASE 2: CARNE ASADA MUST GO DIRECTLY TO MOUTH, DO NOT PASS GO
expected_result="Name: El Alambre
Address: 1188 FRANKLIN ST
Food: Tacos: Burritos: Quesadillas: Nachos Alambres: Choice of Meat: Carne Asada: Carnitas: Pollo: Al Pastor Camarones
Distance (mi): 4.86"
actual_result=$(eat "carne asada" | tail -n 4)
exitcode="$?"
[ "0" = "$exitcode" ] || fail "'eat \"carne asada\"' exited nonzero" "0" "$exitcode"
grep -qzi "carne asada" <<< "$actual_result"
exitcode="$?"
[ "0" = "$exitcode" ] || fail "'eat \"carne asada\"' results should contain the word searched for, in this case 'carne asada'" "something with 'carne asada'" "$actual_result"
[ "$expected_result" = "$actual_result" ] || fail "'eat \"carne asada\"' result was unexpected:" "$expected_result" "$actual_result"
# CASE 3: CAN'T DECIDE, NOT FILTERING
expected_result="5"
actual_result=$(eat | wc -l)
exitcode="$?"
[ "0" = "$exitcode" ] || fail "'eat' with no arguments exited nonzero" "0" "$exitcode"
[ $expected_result = $actual_result ] || fail "'eat' with no arguments should return all $expected_result test entries" "$expected_result" "$actual_result"
}
echo "Running test suite:"
test_suite
green_text "SUCCESS\n"
exit 0
else # This file was run directly without a TEST argument.
# If this file is run directly and not sourced, call the function with any argv's provided
(return 0 2>/dev/null) || eat "$*"
fi
# if this script is sourced, return; otherwise that will error, and then exit 0
return 0 2>/dev/null || exit 0
# That's the trick to avoid interpreting the rest of this file as code
__TESTDATA__
locationid,Applicant,FacilityType,cnn,LocationDescription,Address,blocklot,block,lot,permit,Status,FoodItems,X,Y,Latitude,Longitude,Schedule,dayshours,NOISent,Approved,Received,PriorPermit,ExpirationDate,Location,Fire Prevention Districts,Police Districts,Supervisor Districts,Zip Codes,Neighborhoods (old)
735318,Ziaurehman Amini,Push Cart,30727000,MARKET ST: DRUMM ST intersection,5 THE EMBARCADERO,0234017,0234,017,15MFF-0159,REQUESTED,,6013916.72,2117244.027,37.794331003246846,-122.39581105302317,http://bsm.sfdpw.org/PermitsTracker/reports/report.aspx?title=schedule&report=rptSchedule&params=permit=15MFF-0159&ExportPDF=1&Filename=15MFF-0159_schedule.pdf,,,,20151231,0,03/15/2016 12:00:00 AM,"(37.794331003246846, -122.39581105302317)",4,1,10,28855,6
1652620,"Off the Grid Services, LLC",Truck,2953000,BERRY ST: 03RD ST to 04TH ST (100 - 199),185 BERRY ST,3803005,3803,005,22MFF-00036,APPROVED,everything except for hot dogs,6014943.869,2110666.298,37.77632714778992,-122.39179682107691,http://bsm.sfdpw.org/PermitsTracker/reports/report.aspx?title=schedule&report=rptSchedule&params=permit=22MFF-00036&ExportPDF=1&Filename=22MFF-00036_schedule.pdf,,,11/22/2022 12:00:00 AM,20221019,0,11/15/2023 12:00:00 AM,"(37.77632714778992, -122.39179682107691)",6,2,9,28856,20
1658680,"The New York Frankfurter Co. of CA, Inc. DBA: Annie's Hot Dogs",Push Cart,12195000,STOCKTON ST: OFARRELL ST to GEARY ST (100 - 199),101 STOCKTON ST,0314002,0314,002,22MFF-00069,APPROVED,Soft Pretzels: hot dogs: sausage: chips: popcorn: soda: espresso: cappucino: pastries: ice cream: italian sausages: shish-ka-bob: churros: juice: water: various drinks,6010660.159,2114587.863,37.786856111883054,-122.40689189299718,http://bsm.sfdpw.org/PermitsTracker/reports/report.aspx?title=schedule&report=rptSchedule&params=permit=22MFF-00069&ExportPDF=1&Filename=22MFF-00069_schedule.pdf,,,11/09/2022 12:00:00 AM,20221109,1,11/15/2023 12:00:00 AM,"(37.786856111883054, -122.40689189299718)",5,10,10,28852,6
1660844,El Alambre,Truck,5817000,FRANKLIN ST: MYRTLE ST to GEARY BLVD (1150 - 1199),1188 FRANKLIN ST,0714017,0714,017,22MFF-00095,APPROVED,Tacos: Burritos: Quesadillas: Nachos Alambres: Choice of Meat: Carne Asada: Carnitas: Pollo: Al Pastor Camarones,6006108.969,2114104.044,37.785271949066775,-122.42260358516438,http://bsm.sfdpw.org/PermitsTracker/reports/report.aspx?title=schedule&report=rptSchedule&params=permit=22MFF-00095&ExportPDF=1&Filename=22MFF-00095_schedule.pdf,,,11/22/2022 12:00:00 AM,20221122,0,11/15/2023 12:00:00 AM,"(37.785271949066775, -122.42260358516438)",13,9,11,28858,41
1660523,Natan's Catering,Truck,10658000,POTRERO AVE: 10TH ST \ BRANNAN ST \ DIVISION ST to ALAMEDA ST (1 - 99),66 POTRERO AVE,3906004,3906,004,22MFF-00073,APPROVED,Burgers: melts: hot dogs: burritos:sandwiches: fries: onion rings: drinks,6010061.632,2107930.589,37.76854328902419,-122.40849289243862,http://bsm.sfdpw.org/PermitsTracker/reports/report.aspx?title=schedule&report=rptSchedule&params=permit=22MFF-00073&ExportPDF=1&Filename=22MFF-00073_schedule.pdf,,,11/18/2022 12:00:00 AM,20221118,1,11/15/2023 12:00:00 AM,"(37.76854328902419, -122.40849289243862)",8,4,8,28853,19
@pmarreck
Copy link
Author

Recent edits (4/28/2023) include: Adding Nix dependency management if it exists, macOS compatibility fixes, permitting running the file directly with filter arguments instead of having to call the function (this also guarantees the right dependencies are available, and automatically installs them and uses them if you do not, if you have Nix installed)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment