Last active
October 31, 2024 14:14
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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¶ms=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¶ms=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¶ms=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¶ms=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¶ms=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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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)