-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlinux-weather-bar.sh
More file actions
executable file
·1924 lines (1710 loc) · 66.8 KB
/
linux-weather-bar.sh
File metadata and controls
executable file
·1924 lines (1710 loc) · 66.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
#
# Weather & Moon Phase Display Script
# Fetches and displays current weather information from OpenWeatherMap API
# and optionally shows moon phase during nighttime hours.
#
# EMIT_JSON_OUTPUT - Set to "true" to output JSON response (default: false)
#
# Exit on error, undefined variables, and pipe failures
set -euo pipefail
# ─── API Configuration ────────────────────────────────────────────────────────
# API keys and location are loaded from .weather_config (git-ignored)
# Config file is auto-created from .weather_config.template on first run
readonly API_BASE_URL="https://api.openweathermap.org/data/2.5"
readonly WEATHER_API_URL="${API_BASE_URL}/weather"
# Default to FREE plan (3-hourly forecast)
FORECAST_API_URL="${API_BASE_URL}/forecast"
readonly SUN_DATA_FILE="${HOME}/.cache/weather/sun-data.json"
readonly MOON_API_URL="https://astroapi.byhrast.com/moon.php"
# Path to moon data cache file
readonly MOON_DATA_FILE="${HOME}/.cache/weather/moon-data.json"
# Path to weather and forecast API response cache files
readonly WEATHER_DATA_FILE="${HOME}/.cache/weather/weather-data.json"
readonly FORECAST_DATA_FILE="${HOME}/.cache/weather/forecast-data.json"
# ─── Shared Moon Phase Data (arrays) ───────────────────────────────────────────
readonly -a MOON_PHASE_NAMES=("New Moon" "Waxing Crescent" "First Quarter" "Waxing Gibbous" "Full Moon" "Waning Gibbous" "Last Quarter" "Waning Crescent")
readonly -a MOON_PHASE_NAMES_BN=("অমাবস্যা" "শুক্লপক্ষের বাঁকা চাঁদ" "শুক্লপক্ষের অর্ধচন্দ্র" "শুক্লপক্ষের বর্ধমান চাঁদ" "পূর্ণিমা" "কৃষ্ণপক্ষের ক্ষীয়মাণ চাঁদ" "কৃষ্ণপক্ষের অর্ধচন্দ্র" "কৃষ্ণপক্ষের বাঁকা চাঁদ")
readonly -a MOON_PHASE_EMOJIS=("🌑" "🌒" "🌓" "🌔" "🌕" "🌖" "🌗" "🌘")
#######################################
# Load configuration from .weather_config or create from template
# Globals:
# (sets API_KEY, MOON_API_KEY, LOCATION, TIMEZONE)
# Returns:
# 0 always
#######################################
load_or_create_config() {
local script_dir config_file template_file
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
config_file="${script_dir}/.weather_config"
template_file="${script_dir}/.weather_config.template"
if [[ -f "$config_file" ]]; then
# Source existing config (overrides defaults)
source "$config_file"
elif [[ -f "$template_file" ]]; then
# Create config from template
cp "$template_file" "$config_file"
source "$config_file"
else
# Fallback
echo "Warning: Config file not found at $config_file" >&2
fi
}
# Load configuration
load_or_create_config
# ─── Set Configuration Defaults (for set -u safety) ────────────────────────────
: "${API_KEY_TYPE:=FREE}"
: "${MAX_CONNECTIVITY_RETRIES:=5}"
: "${CONNECTIVITY_RETRY_DELAY:=5}"
: "${FEELS_LIKE_THRESHOLD:=5}"
: "${SHOW_RAIN_FORECAST:=true}"
: "${RAIN_FORECAST_THRESHOLD:=0.80}"
: "${RAIN_FORECAST_WINDOW:=3}"
# ─── Sunrise & Sunset ─────────────────────────────────────────────────────────
: "${SHOW_SUNRISE_SUNSET:=true}"
: "${SUNRISE_WARNING_THRESHOLD:=45}"
: "${SUNSET_WARNING_THRESHOLD:=45}"
: "${SHOW_SUNRISE_SUNSET_DURING_RAIN:=true}"
: "${SHOW_SUNRISE_SUNSET_WITH_RAIN_FORECAST:=true}"
# ─── Moonrise & Moonset ───────────────────────────────────────────────────────
: "${SHOW_MOONRISE_MOONSET:=true}"
: "${MOONRISE_WARNING_THRESHOLD:=45}"
: "${MOONSET_WARNING_THRESHOLD:=45}"
: "${SHOW_MOONRISE_MOONSET_DURING_DAYTIME:=false}"
: "${SUPPRESS_NOT_VISIBLE_MOONRISE_MOONSET:=false}"
: "${SHOW_MOONRISE_MOONSET_DURING_RAIN:=true}"
: "${SHOW_MOONRISE_MOONSET_WITH_RAIN_FORECAST:=false}"
# ─── Moon Phase ───────────────────────────────────────────────────────────────
: "${MOON_PHASE_ENABLED:=true}"
: "${MOON_DATA_CACHE_MAX_AGE:=2}"
: "${MOON_PHASE_WINDOW_START:=moonrise}"
: "${MOON_PHASE_WINDOW_DURATION:=moonset}"
: "${SHOW_MOONPHASE_DURING_DAYTIME:=false}"
: "${SUPPRESS_NOT_VISIBLE_MOONPHASE:=false}"
: "${SHOW_MOON_PHASE_DURING_RAIN:=true}"
: "${SHOW_MOON_PHASE_WITH_RAIN_FORECAST:=false}"
: "${SHOW_MOONPHASE_BENGALI:=false}"
: "${SHOW_MOONPHASE_BILINGUAL:=false}"
: "${SHOW_APSIDAL_MOON_EVENTS:=true}"
: "${SUPPRESS_NOT_VISIBLE_NIGHT_APSIDAL_MOON_EVENTS:=false}"
: "${SHOW_FULL_MOON_FOLK_NAME:=true}"
# ─── Validate Required Credentials ────────────────────────────────────────────
if [[ -z "${MOON_API_KEY:-}" ]] && [[ "${MOON_PHASE_ENABLED}" == "true" ]]; then
echo "Warning: MOON_API_KEY not set in config; moon phase display disabled" >&2
MOON_PHASE_ENABLED=false
fi
# ─── Adjust Forecast API based on API plan ────────────────────────────────────
# FREE plan (default) uses 3-hourly forecast (/forecast)
# PRO or other plans use hourly forecast (/forecast/hourly)
if [[ "$API_KEY_TYPE" != "FREE" ]]; then
FORECAST_API_URL="${API_BASE_URL}/forecast/hourly"
fi
# ─── Icon Mapping ─────────────────────────────────────────────────────────────
declare -Ar WEATHER_ICONS=(
["01d"]="☀️" ["02d"]="⛅️" ["03d"]="☁️" ["04d"]="☁️"
["09d"]="🌧️" ["10d"]="🌦️" ["11d"]="⛈️" ["13d"]="❄️" ["50d"]="🌫️"
["01n"]="🌕" ["02n"]="☁️" ["03n"]="☁️" ["04n"]="☁️"
["09n"]="🌧️" ["10n"]="☔️" ["11n"]="⛈️" ["13n"]="❄️" ["50n"]="🌫️"
)
#######################################
# Check internet connectivity with retry logic
# Globals:
# MAX_CONNECTIVITY_RETRIES
# CONNECTIVITY_RETRY_DELAY
# Returns:
# 0 if connected, 1 otherwise
#######################################
check_connectivity() {
local attempt connectivity
for attempt in $(seq 1 "$MAX_CONNECTIVITY_RETRIES"); do
if command -v nmcli &>/dev/null; then
# nmcli failure itself should not abort — capture safely
connectivity=$(nmcli networking connectivity check 2>/dev/null) || connectivity="none"
if [[ "$connectivity" == "full" ]]; then
return 0
fi
else
# Fallback: try pinging a reliable server
if ping -c 1 -W 2 8.8.8.8 &>/dev/null; then
return 0
fi
fi
if [[ $attempt -lt "$MAX_CONNECTIVITY_RETRIES" ]]; then
sleep "$CONNECTIVITY_RETRY_DELAY"
fi
done
return 1
}
#######################################
# Capitalize first letter of each word
# Arguments:
# $1 - Input string
# Outputs:
# Capitalized string
#######################################
capitalize_words() {
local input="$1"
# Using awk for better portability
awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2))}1' <<<"$input"
}
#######################################
# Format temperature value
# Arguments:
# $1 - Temperature value
# Outputs:
# Formatted temperature (removes .0 if present)
#######################################
format_temperature() {
local temp="$1"
printf "%.1f" "$temp" | sed 's/\.0$//'
}
#######################################
# Get weather icon emoji
# Arguments:
# $1 - Icon code from API
# Outputs:
# Emoji character
#######################################
get_weather_icon() {
local icon_code="$1"
echo "${WEATHER_ICONS[$icon_code]:-🌡️}"
}
#######################################
# Format time
# Arguments:
# $1 - epoch time
# Outputs:
# Formatted time string (e.g., "6:30 PM")
#######################################
format_time() {
local epoch="$1"
date -d "@${epoch}" +"%-I:%M %p" 2>/dev/null ||
date -r "$epoch" +"%-I:%M %p" 2>/dev/null
}
#######################################
# Convert a HH:MM (24-hour) time string to today's Unix epoch.
# Rejects non-time strings (e.g. "Not visible") before calling date.
# Uses GNU date with BSD date as fallback.
#
# Arguments:
# $1 - Time string in HH:MM format
# Outputs:
# Epoch integer on success, empty string on failure
# Returns:
# 0 always
#######################################
hhmm_to_epoch() {
local date_str="$1" # NEW: YYYY-MM-DD
local hhmm="$2"
# Validate HH:MM
[[ "$hhmm" =~ ^[0-9]{1,2}:[0-9]{2}$ ]] || { echo "0"; return 0; }
# Validate date
[[ -n "$date_str" ]] || { echo "0"; return 0; }
date -d "${date_str} ${hhmm}" +%s 2>/dev/null || \
date -j -f "%Y-%m-%d %H:%M" "${date_str} ${hhmm}" +%s 2>/dev/null || \
echo "0"
}
#######################################
# Fetch weather data from API
# Globals:
# LOCATION, API_KEY, WEATHER_API_URL
# Outputs:
# JSON response from API
# Returns:
# 0 on success, 1 on failure
#######################################
fetch_weather_data() {
local url="${WEATHER_API_URL}?${LOCATION}&appid=${API_KEY}&units=metric"
local response
response=$(curl -sf "$url" 2>/dev/null) || return 1
# Validate JSON
echo "$response" | jq -e . >/dev/null 2>&1 || return 1
# Persist formatted response to cache file (skip if called from another script)
if [[ "${DISABLE_CACHE_WRITE:-false}" != "true" ]]; then
mkdir -p "$(dirname "$WEATHER_DATA_FILE")"
echo "$response" | jq '.' > "$WEATHER_DATA_FILE"
fi
echo "$response"
}
#######################################
# Parse weather data from JSON response
# Arguments:
# $1 - JSON response
# Outputs:
# Tab-separated values: weather_main description icon temp feels_like sunrise_epoch sunset_epoch
#######################################
parse_weather_data() {
local response="$1"
jq -re '[
.weather[0].main // "",
.weather[0].description // "",
.weather[0].icon // "",
.main.temp // 0,
.main.feels_like // 0,
.sys.sunrise // 0,
.sys.sunset // 0
] | @tsv' <<<"$response"
}
save_sun_data() {
local sunrise_epoch="$1"
local sunset_epoch="$2"
local now
now=$(date +%s)
# Only save during daytime — overnight the API returns tomorrow's values
# and we need yesterday's sunset to remain in cache
# Guard both sunrise AND sunset to prevent corrupted cache
(( sunrise_epoch > 0 && sunset_epoch > 0 && now >= sunrise_epoch )) || return 0
local today cached_date
today=$(date +"%d/%m/%Y")
# Check if file already contains today's data — don't overwrite
if [[ -f "$SUN_DATA_FILE" ]]; then
cached_date=$(jq -r '.date // ""' <"$SUN_DATA_FILE" 2>/dev/null) || cached_date=""
if [[ "$cached_date" == "$today" ]]; then
# Cache already has today's data, no need to refresh
return 0
fi
fi
mkdir -p "$(dirname "$SUN_DATA_FILE")"
jq -n \
--arg date "$today" \
--argjson sunrise "$sunrise_epoch" \
--argjson sunset "$sunset_epoch" \
'{date: $date, sunrise: $sunrise, sunset: $sunset}' > "$SUN_DATA_FILE"
}
#######################################
# Resolve the effective sunrise for the solar window ceiling.
# During evening/night (after today's sunrise), the relevant sunrise
# is TOMORROW's, not today's. Add 86400 seconds to get it.
#
# Arguments:
# $1 - api_sunrise_epoch (today's sunrise from API)
# Outputs:
# Epoch of the next sunrise (tomorrow's if we're past today's)
#######################################
get_effective_sunrise() {
local api_sunrise_epoch="$1"
local now
now=$(date +%s)
# If we're past today's sunrise, the next sunrise is tomorrow's
if (( now >= api_sunrise_epoch )); then
echo $(( api_sunrise_epoch + 86400 ))
else
echo "$api_sunrise_epoch"
fi
}
#######################################
# Determine the effective sunset to use for the Solar Window.
#
# Context:
# - Before midnight (daytime/evening): use TODAY's sunset from API
# - After midnight (overnight): use YESTERDAY's sunset from cache
#
# Logic:
# If current time is past today's sunrise, we're in "today's" timeframe (daytime/evening).
# Use today's sunset directly.
#
# If current time is before today's sunrise, we're in "overnight" (yesterday's sunset → today's sunrise).
# Fetch the cache and use yesterday's sunset from there.
#
# If cache is missing/stale and we're overnight:
# Estimate yesterday's sunset as (api_sunrise_epoch - 24h).
# This typically differs by ~3min from reality but is acceptable.
#
# Arguments:
# $1 - api_sunset_epoch (today's sunset from API)
# $2 - api_sunrise_epoch (today's sunrise from API)
# Outputs:
# Epoch of the sunset to use for the Solar Window
# Returns:
# 0 always
#######################################
get_effective_sunset() {
local api_sunset_epoch="$1"
local api_sunrise_epoch="$2"
local now
now=$(date +%s)
# If we're past today's sunrise, it's daytime/evening — use API sunset as-is
if (( now >= api_sunrise_epoch )); then
echo "$api_sunset_epoch"
return 0
fi
# Overnight: try yesterday's sunset from cache
if [[ -f "$SUN_DATA_FILE" ]]; then
local cached_sunset
cached_sunset=$(jq -r '.sunset // 0' <"$SUN_DATA_FILE" 2>/dev/null)
if (( cached_sunset > 0 )); then
echo "$cached_sunset"
return 0
fi
fi
# Cache missing/stale — estimate yesterday's sunset from today's sunrise
# Sunset is typically ~12.5 hours after sunrise; this error is acceptable
echo $(( api_sunrise_epoch - 43200 ))
}
#######################################
# Fetch forecast data from API
# Globals:
# LOCATION, API_KEY, FORECAST_API_URL
# Outputs:
# JSON response
# Returns:
# 0 on success, 1 on failure
#######################################
fetch_forecast_data() {
local url="${FORECAST_API_URL}?${LOCATION}&appid=${API_KEY}&units=metric"
local response
response=$(curl -sf "$url" 2>/dev/null) || return 1
echo "$response" | jq -e . >/dev/null 2>&1 || return 1
# Persist formatted response to cache file (skip if called from another script)
if [[ "${DISABLE_CACHE_WRITE:-false}" != "true" ]]; then
mkdir -p "$(dirname "$FORECAST_DATA_FILE")"
echo "$response" | jq '.' > "$FORECAST_DATA_FILE"
fi
echo "$response"
}
#######################################
# Get next rain timing and probability
# within RAIN_FORECAST_WINDOW
# Arguments:
# $1 - JSON forecast response
# Outputs:
# "rain_epoch probability" OR empty
#######################################
get_rain_forecast() {
local response="$1"
local result
result=$(jq -r \
--argjson threshold "$RAIN_FORECAST_THRESHOLD" \
--argjson window "$((RAIN_FORECAST_WINDOW * 3600))" '
now as $now
| .list
| map(
select(
(.dt - $now) <= $window and
(.pop >= $threshold)
)
)
| if length == 0 then
empty
else
.[0] as $first
| [$first.dt, ($first.pop * 100 | floor), ($first.weather[0].description // ""), ($first.weather[0].icon // "") ]
| @tsv
end
' <<<"$response" 2>/dev/null) || return 1
# empty output is valid (no rain forecast) — not an error
echo "$result"
}
#######################################
# Format rain warning text
# Arguments:
# $1 - Rain epoch time
# $2 - Probability %
# $3 - Description string
# $4 - Icon emoji
# Outputs:
# Formatted rain warning string
#######################################
format_rain_warning() {
local rain_epoch="$1"
local probability="$2"
local description="$3"
local icon="$4"
# Convert to local time string (e.g., 6:45 PM)
local rain_time
rain_time=$(format_time "$rain_epoch")
if [[ -z "$rain_time" ]]; then
rain_time="soon" # graceful degradation
fi
# 24h threshold logic
local now rain_date_fmt=""
now=$(date +%s)
if (( rain_epoch - now > 86400 )); then
rain_date_fmt=$(date -d "@${rain_epoch}" +"%-d %B" 2>/dev/null || date -r "$rain_epoch" +"%-d %B")
fi
# Description normalization
local rain_desc
if [[ "$description" =~ [Rr]ain|[Dd]rizzle|[Tt]hunderstorm ]]; then
rain_desc=$(capitalize_words "$description")
else
rain_desc="Rain likely"
icon="🌧️"
fi
# Build output
if [[ -n "$rain_date_fmt" ]]; then
echo "${icon} ${rain_desc} ≈ ${rain_date_fmt} ${rain_time^^} (${probability}%)"
else
echo "${icon} ${rain_desc} ≈ ${rain_time^^} (${probability}%)"
fi
}
#######################################
# Validate a moon-data JSON string for structural correctness.
#
# Checks:
# - Non-empty string
# - Parses as valid JSON
# - .moonrise is a JSON number (not a string, null, or other non-numeric value)
# - .moonset is a JSON number (not a string, null, or other non-numeric value)
#
# Note: zero is accepted as a valid number (moon not visible today);
# type("number") is the contract — downstream callers decide what to do
# with the actual value.
#
# Arguments:
# $1 - JSON string to validate
# Returns:
# 0 (true) if valid
# 1 (false) if empty, unparseable, or moonrise/moonset are not numbers
#######################################
validate_moon_json() {
local json="$1"
[[ -n "$json" ]] || return 1
jq -e '
(.moonrise | type) == "number" and
(.moonset | type) == "number"
' <<<"$json" >/dev/null 2>&1
}
#######################################
# Load moon data cache from MOON_DATA_FILE.
# Creates the cache directory and an empty JSON object if absent.
# If the file exists but contains corrupt data (e.g. moonrise/moonset
# are non-numeric), the file is reset to "{}" and a warning is emitted.
#
# Globals:
# MOON_DATA_FILE
# Arguments:
# (none)
# Outputs:
# Cached JSON string, or "{}" if file did not exist or was corrupt
# Returns:
# 0 always
#######################################
load_moon_cache() {
mkdir -p "$(dirname "$MOON_DATA_FILE")"
if [[ ! -f "$MOON_DATA_FILE" ]]; then
echo "{}" > "$MOON_DATA_FILE"
echo "{}"
return 0
fi
local cache
cache=$(cat "$MOON_DATA_FILE")
# Accept empty placeholder "{}" without running full field validation
if [[ "$cache" == "{}" ]]; then
echo "{}"
return 0
fi
if ! validate_moon_json "$cache"; then
# echo "Warning: moon-data.json is corrupt (moonrise/moonset must be numbers); resetting cache" >&2
echo "{}" > "$MOON_DATA_FILE"
echo "{}"
return 0
fi
echo "$cache"
}
# -----------------------------------------------------------------------------
# call_moon_api
#
# Purpose:
# Calls the configured Moon API endpoint and returns validated JSON output.
#
# Inputs:
# $1 - date_param (required)
# $2 - time_param (required)
#
# Behavior:
# - Builds a request URL using:
# MOON_API_URL, MOON_API_KEY, LOCATION, TIMEZONE
# - Sends a GET request via curl
# - Ensures request succeeds (non-zero exit → failure)
# - Validates that response contains expected field: ".moonrise"
# - Adds an additional field:
# retrieved_at → current system date-time (ISO-8601 format)
# - Returns the modified JSON response
#
# Output:
# JSON response from API with added fields:
# {
# ... original fields ...,
# "retrieved_at": "1745207588",
# "moonrise": 1745207940,
# "moonset": 1745234100
# }
#
# Failure cases:
# - Missing input parameters → return 1
# - curl failure → return 1
# - invalid or unexpected JSON → return 1
# -----------------------------------------------------------------------------
call_moon_api() {
local date_param="$1"
local time_param="$2"
local url response
# Validate inputs
[ -z "$date_param" ] && return 1
[ -z "$time_param" ] && return 1
url="${MOON_API_URL}?key=${MOON_API_KEY}&${LOCATION}&tz=${TIMEZONE}&date=${date_param}&time=${time_param}"
response=$(curl -sf "$url" 2>/dev/null) || return 1
# Sanity-check: ensure expected field is present
echo "$response" | jq -e '.moonrise' >/dev/null 2>&1 || return 1
# Convert all values to strings (epochs are injected afterwards as plain integers)
response=$(echo "$response" | jq 'walk(if type == "number" or type == "boolean" then tostring else . end)')
# ── Replace HH:MM strings with epoch integers ─────────────────────────────
# moonrise: always anchored to the response date.
# moonset: same date normally; next day when moonset HH:MM < moonrise HH:MM
# (midnight crossing).
local api_date moonrise_hhmm moonset_hhmm
api_date=$(echo "$response" | jq -r '.date // ""')
moonrise_hhmm=$(echo "$response" | jq -r '.moonrise // ""')
moonset_hhmm=$(echo "$response" | jq -r '.moonset // ""')
local norm_date norm_date_next moonrise_ep moonset_ep
norm_date=""
norm_date_next=""
moonrise_ep=0
moonset_ep=0
if [[ "$api_date" =~ ^[0-9]{2}/[0-9]{2}/[0-9]{4}$ ]]; then
norm_date=$(date -d "$(echo "$api_date" | awk -F/ '{print $3"-"$2"-"$1}')" +"%Y-%m-%d" 2>/dev/null)
norm_date_next=$(date -d "$norm_date + 1 day" +"%Y-%m-%d" 2>/dev/null)
fi
if [[ -n "$norm_date" ]]; then
moonrise_ep=$(hhmm_to_epoch "$norm_date" "$moonrise_hhmm")
moonrise_ep=${moonrise_ep:-0}
# Determine which date moonset belongs to:
# if moonset HH:MM < moonrise HH:MM → moonset is next calendar day
local moonset_date="$norm_date"
if [[ "$moonrise_hhmm" =~ ^[0-9]{1,2}:[0-9]{2}$ ]] && \
[[ "$moonset_hhmm" =~ ^[0-9]{1,2}:[0-9]{2}$ ]]; then
local rise_mins set_mins
rise_mins=$(echo "$moonrise_hhmm" | awk -F: '{print $1*60+$2}')
set_mins=$(echo "$moonset_hhmm" | awk -F: '{print $1*60+$2}')
if (( set_mins < rise_mins )) && [[ -n "$norm_date_next" ]]; then
moonset_date="$norm_date_next"
fi
fi
moonset_ep=$(hhmm_to_epoch "$moonset_date" "$moonset_hhmm")
moonset_ep=${moonset_ep:-0}
fi
# Write epochs back into .moonrise / .moonset / .retrieved_at as plain integers
response=$(echo "$response" | jq \
--argjson mrise "$moonrise_ep" \
--argjson mset "$moonset_ep" \
--argjson ts "$(date +%s)" \
'.moonrise = $mrise | .moonset = $mset | .retrieved_at = $ts')
echo "$response"
}
#######################################
# Fetch fresh moon data from astroapi and return validated JSON.
#
# Globals:
# MOON_API_KEY, MOON_API_URL, LOCATION, TIMEZONE
# Arguments:
# $1 - needed_date in DD/MM/YYYY format
# Outputs:
# Fresh JSON string on success, nothing on failure
# Returns:
# 0 on success, 1 on failure
#######################################
fetch_moon_data() {
local needed_date="$1"
local time_param
time_param=$(date +"%H:%M")
call_moon_api "$needed_date" "$time_param"
}
#######################################
# Write validated moon data to cache file.
# Only writes if the fetched date matches the needed date
# and the JSON passes structural validation (moonrise/moonset are numbers).
#
# Arguments:
# $1 - fresh moon data JSON
# $2 - needed_date (DD/MM/YYYY)
# Returns:
# 0 always
#######################################
_save_moon_cache() {
local data="$1"
local needed_date="$2"
local fetched_date
fetched_date=$(jq -r '.date // ""' <<<"$data" 2>/dev/null)
[[ "$fetched_date" == "$needed_date" ]] || return 0
# Refuse to persist structurally invalid data
if ! validate_moon_json "$data"; then
# echo "Warning: refusing to cache moon data — moonrise/moonset are not numbers" >&2
return 0
fi
# Skip saving if called from another script
if [[ "${DISABLE_CACHE_WRITE:-false}" != "true" ]]; then
mkdir -p "$(dirname "$MOON_DATA_FILE")"
printf '%s\n' "$data" | jq '.' > "$MOON_DATA_FILE"
fi
}
#######################################
# Determine whether a date-matched moon cache entry needs refreshing.
#
# A cache is stale when both of the following are true:
# - We are inside the active lunar window (past window_start, before window_end)
# (using inferred window if any rise/set data is missing)
# AND either:
# a) The cache was fetched before the window start (position/illumination stale), OR
# b) The cache has exceeded MOON_DATA_CACHE_MAX_AGE since retrieval
#
# The inferred window is used instead of requiring both moonrise and moonset,
# ensuring cache freshness even when partial lunar data is returned.
#
# Arguments:
# $1 - cache JSON string
# $2 - now (epoch)
# $3 - cached_moonrise_epoch (from cache; 0 if unknown/invalid)
# $4 - cached_moonset_epoch (from cache; 0 if unknown/invalid)
# Returns:
# 0 (true) if stale
# 1 (false) if fresh or indeterminate
#######################################
_is_moon_cache_stale() {
local cache="$1"
local now="$2"
local cached_moonrise_epoch="${3:-0}"
local cached_moonset_epoch="${4:-0}"
local retrieved_at_epoch=0
retrieved_at_epoch=$(jq -r '.retrieved_at // 0' <<<"$cache" 2>/dev/null)
retrieved_at_epoch=${retrieved_at_epoch:-0}
# Cannot determine staleness without fetch timestamp
(( retrieved_at_epoch > 0 )) || return 1
# Get effective lunar window (actual or inferred from partial data)
local window_start window_end
IFS=$'\t' read -r window_start window_end \
<<<"$(get_effective_lunar_window "$cached_moonrise_epoch" "$cached_moonset_epoch")"
# Cannot determine staleness without valid window bounds
(( window_start > 0 && window_end > 0 )) || return 1
# Only check staleness inside the active lunar window
(( now >= window_start )) || return 1
(( now < window_end )) || return 1
# (a) Fetched before window start — position/illumination data is stale
if (( retrieved_at_epoch < window_start )); then
return 0
fi
# (b) Older than configured max age while still inside lunar window
local max_age_seconds=$(( MOON_DATA_CACHE_MAX_AGE * 3600 ))
if (( now - retrieved_at_epoch > max_age_seconds )); then
return 0
fi
return 1 # fresh
}
#######################################
# Return current moon data — from cache if valid, otherwise fetched live.
#
# Date selection logic:
# If the cached moonset epoch is still in the future, the active lunar
# cycle belongs to whatever date the cache holds (today or yesterday for
# overnight midnight-crossings). Once moonset has passed, today's data
# is needed. This single rule replaces all explicit sunrise/overnight
# checks that were previously required.
#
# Cache validation:
# When the cached date matches needed_date, staleness is checked via
# _is_moon_cache_stale. Fresh cache is returned immediately; stale cache
# triggers a targeted re-fetch for needed_date.
#
# Fallback:
# On fetch failure the existing cache is returned as-is (graceful
# degradation). If no cache exists at all an empty string is returned.
#
# Globals:
# MOON_DATA_FILE, MOON_API_URL, MOON_API_KEY, LOCATION, TIMEZONE,
# MOON_DATA_CACHE_MAX_AGE
# Arguments:
# (none)
# Outputs:
# JSON string, or empty string on total failure
# Returns:
# 0 always
#######################################
get_moon_data() {
local cache now today needed_date cached_date cached_moonrise_epoch cached_moonset_epoch
cache=$(load_moon_cache)
now=$(date +%s)
today=$(date +"%d/%m/%Y")
cached_date=$(jq -r '.date // ""' <<<"$cache" 2>/dev/null) || cached_date=""
cached_moonrise_epoch=$(jq -r '.moonrise // 0' <<<"$cache" 2>/dev/null)
cached_moonrise_epoch=${cached_moonrise_epoch:-0}
cached_moonset_epoch=$(jq -r '.moonset // 0' <<<"$cache" 2>/dev/null)
cached_moonset_epoch=${cached_moonset_epoch:-0}
# Determine needed date:
# Active lunar cycle → keep the date the cache already holds (today or yesterday).
# Uses inferred window when data is partial, maintaining cache validity.
# Moonset past (or no usable cache) → need today's data.
local cache_window_end
cache_window_end=$(cut -d$'\t' -f2 <<< "$(get_effective_lunar_window "$cached_moonrise_epoch" "$cached_moonset_epoch")")
if [[ -n "$cached_date" ]] && (( cache_window_end > 0 && cache_window_end > now )); then
needed_date="$cached_date"
else
needed_date="$today"
fi
# Cache hit: date matches — return immediately or refresh if stale
if [[ "$cached_date" == "$needed_date" ]]; then
if ! _is_moon_cache_stale "$cache" "$now" "$cached_moonrise_epoch" "$cached_moonset_epoch"; then
echo "$cache"
return 0
fi
# Stale — fetch fresh; fall back to existing cache on failure
local fresh
fresh=$(fetch_moon_data "$needed_date") || true
if [[ -n "$fresh" ]]; then
_save_moon_cache "$fresh" "$needed_date"
echo "$fresh"
return 0
fi
echo "$cache"
return 0
fi
# Cache miss or wrong date — fetch fresh data for needed_date
local fresh
fresh=$(fetch_moon_data "$needed_date") || true
if [[ -n "$fresh" ]]; then
_save_moon_cache "$fresh" "$needed_date"
echo "$fresh"
return 0
fi
# Complete failure — return existing cache or empty string
if [[ -n "$cache" && "$cache" != "{}" ]]; then
echo "$cache"
return 0
fi
echo ""
}
#######################################
# Parse moonrise and moonset HH:MM strings from moon data JSON
# and return the epoch integers stored in .moonrise / .moonset.
#
# Epochs are computed by call_moon_api at fetch time and stored directly
# in .moonrise and .moonset, replacing the original HH:MM strings:
# .moonrise — epoch anchored to the API response date
# .moonset — epoch on same date, or next day if moonset HH:MM
# < moonrise HH:MM (midnight crossing already resolved)
#
# Arguments:
# $1 - Moon data JSON string
# $2 - (unused, kept for call-site compatibility)
# Outputs:
# Tab-separated: moonrise_epoch (from .moonrise)<TAB>moonset_epoch (from .moonset)
# Returns:
# 0 always
#######################################
parse_moon_times() {
local moon_data="$1"
# $2 (sunrise_epoch) kept in signature for call-site compatibility — not needed
# now that epochs are pre-computed and stored in the JSON.
local moonrise_epoch moonset_epoch
moonrise_epoch=$(jq -r '.moonrise // 0' <<<"$moon_data" 2>/dev/null)
moonset_epoch=$(jq -r '.moonset // 0' <<<"$moon_data" 2>/dev/null)
moonrise_epoch=${moonrise_epoch:-0}
moonset_epoch=${moonset_epoch:-0}
printf "%s\t%s\n" "$moonrise_epoch" "$moonset_epoch"
}
#######################################
# Check if moon data JSON contains valid moon times (strict validation).
# Ensures times match HH:MM format, rejecting "Not visible" and other invalid strings.
#
# Arguments:
# $1 - Moon data JSON string
# Returns:
# 0 (true) if has valid moonrise or moonset in HH:MM format
# 1 (false) if empty, missing both fields, or contains invalid strings
#######################################
is_valid_moon_data() {
local moon_data="$1"
[[ -n "$moon_data" ]] || return 1
# Valid if at least one pre-computed epoch is a positive integer
jq -e '(
((.moonrise // 0) | type == "number" and . > 0) or
((.moonset // 0) | type == "number" and . > 0)
)' <<<"$moon_data" >/dev/null 2>&1
}
#######################################
# Resolve the moon phase index (0-7) from API data.
# Only returns valid index if API provides recognized phase name.
# Returns nothing if API data missing or phase unrecognized.
#
# Arguments:
# $1 - Moon data JSON string (may be empty)
# Outputs:
# Integer index 0-7, or nothing if not available from API
# Returns:
# 0 always
#######################################
resolve_phase_index() {
local moon_data="$1"
[[ -z "$moon_data" ]] && return 0
local api_phase
api_phase=$(jq -r '.phase // ""' <<<"$moon_data" 2>/dev/null)
[[ -z "$api_phase" ]] && return 0
local i
for i in "${!MOON_PHASE_NAMES[@]}"; do
if [[ "${MOON_PHASE_NAMES[i],,}" == "${api_phase,,}" ]]; then
echo "$i"
return 0
fi
done
}
#######################################
# Format and return the moon phase string for display.
#
# Globals:
# SHOW_MOONPHASE_BENGALI - true → Bengali only
# SHOW_MOONPHASE_BILINGUAL - true → English + Bengali (overrides BENGALI)
# MOON_PHASE_EMOJIS, MOON_PHASE_NAMES, MOON_PHASE_NAMES_BN
# SHOW_FULL_MOON_FOLK_NAME - true → replace "Full Moon" with folk name
# Arguments:
# $1 - Phase index (0-7)
# $2 - Apsidal syzygy label (optional, e.g. "Supermoon", "Micromoon")
# When provided and non-empty, appended as "(label)" after the phase name.
# In bilingual mode it is always appended in English after the Bengali name.
# $3 - Moonrise epoch (optional, 0 if unknown; used for folk name lookup)
# Outputs:
# English: "🌕 Flower Moon" or "🌕 Flower Moon (Supermoon)"
# Bengali: "🌕 পূর্ণিমা" or "🌕 পূর্ণিমা (Supermoon)"
# Bilingual: "🌕 Flower Moon (পূর্ণিমা)" or "🌕 Flower Moon (পূর্ণিমা) (Supermoon)"
# Returns:
# 0 always
#######################################
get_moon_phase() {
local phase_index="$1"
local syzygy_label="${2:-}"
local moon_data="${3:-}"
# For Full Moon (phase_index == 4), resolve the English label using folk name if applicable.
local english_label="${MOON_PHASE_NAMES[$phase_index]}"
if (( phase_index == 4 )); then
english_label=$(resolve_full_moon_label "Full Moon" "$moon_data")
fi
local base
if [[ "$SHOW_MOONPHASE_BILINGUAL" == "true" ]]; then
base="${MOON_PHASE_EMOJIS[$phase_index]} ${english_label} (${MOON_PHASE_NAMES_BN[$phase_index]})"
elif [[ "$SHOW_MOONPHASE_BENGALI" == "true" ]]; then
base="${MOON_PHASE_EMOJIS[$phase_index]} ${MOON_PHASE_NAMES_BN[$phase_index]}"
else
base="${MOON_PHASE_EMOJIS[$phase_index]} ${english_label}"
fi
if [[ -n "$syzygy_label" ]]; then
echo "${base} (${syzygy_label})"